1use crate::embed::{cosine, Embedder};
4use parking_lot::Mutex;
5use serde::{Deserialize, Serialize};
6use std::collections::VecDeque;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct BreakerConfig {
15 pub delta_epsilon: f32,
18 pub window: usize,
20 pub min_tokens: u64,
23 pub action: BreakerAction,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum BreakerAction {
36 Reject,
40 Inject,
42 Abort,
44}
45
46impl Default for BreakerConfig {
47 fn default() -> Self {
48 Self {
54 delta_epsilon: 0.30,
55 window: 3,
56 min_tokens: 1_000,
57 action: BreakerAction::Reject,
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub enum BreakerVerdict {
66 Progressing {
68 delta: f32,
70 },
71 Suspicious {
73 delta: f32,
75 streak: usize,
77 },
78 Tripped {
80 delta: f32,
82 streak: usize,
84 tokens_consumed: u64,
86 action: BreakerAction,
88 },
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub enum BreakerState {
95 Closed,
97 Open,
99}
100
101pub struct SessionLoopState {
106 cfg: BreakerConfig,
107 inner: Mutex<Inner>,
108}
109
110struct Inner {
111 recent_embeddings: VecDeque<Vec<f32>>,
120 streak: usize,
121 tokens_consumed: u64,
122 state: BreakerState,
123}
124
125const RECENT_EMBEDDING_WINDOW: usize = 8;
130
131impl SessionLoopState {
132 pub fn new(cfg: BreakerConfig) -> Self {
134 Self {
135 cfg,
136 inner: Mutex::new(Inner {
137 recent_embeddings: VecDeque::with_capacity(RECENT_EMBEDDING_WINDOW),
138 streak: 0,
139 tokens_consumed: 0,
140 state: BreakerState::Closed,
141 }),
142 }
143 }
144
145 pub fn state(&self) -> BreakerState {
147 self.inner.lock().state
148 }
149 pub fn action(&self) -> BreakerAction {
151 self.cfg.action
152 }
153
154 pub fn observe(&self, embedder: &dyn Embedder, text: &str, step_tokens: u64) -> BreakerVerdict {
157 let embedding = embedder.embed(text);
158 self.observe_embedding(embedding, step_tokens)
159 }
160
161 pub fn observe_embedding(&self, embedding: Vec<f32>, step_tokens: u64) -> BreakerVerdict {
164 self.observe_embedding_with_similarity(embedding, step_tokens, None)
165 }
166
167 pub fn observe_embedding_with_similarity(
170 &self,
171 embedding: Vec<f32>,
172 step_tokens: u64,
173 nearest_similarity: Option<f32>,
174 ) -> BreakerVerdict {
175 let mut inner = self.inner.lock();
176 inner.tokens_consumed = inner.tokens_consumed.saturating_add(step_tokens);
177
178 let embedding_is_finite = embedding.iter().all(|x| x.is_finite());
189 let embedding_is_all_zero = embedding.iter().all(|x| *x == 0.0);
190 let embedding_is_hostile = !embedding_is_finite || embedding_is_all_zero;
191
192 let adjacent_delta = if embedding_is_hostile {
193 0.0
194 } else if inner.recent_embeddings.is_empty() {
195 1.0 } else {
197 inner
200 .recent_embeddings
201 .iter()
202 .map(|prev| 1.0 - cosine(prev, &embedding))
203 .fold(f32::INFINITY, f32::min)
204 };
205 let delta = nearest_similarity
206 .filter(|similarity| similarity.is_finite())
207 .map_or(adjacent_delta, |similarity| {
208 adjacent_delta.min(1.0 - similarity.clamp(-1.0, 1.0))
209 });
210 if !embedding_is_hostile {
214 inner.recent_embeddings.push_back(embedding);
215 if inner.recent_embeddings.len() > RECENT_EMBEDDING_WINDOW {
216 inner.recent_embeddings.pop_front();
217 }
218 }
219
220 if delta < self.cfg.delta_epsilon {
221 inner.streak += 1;
222 } else {
223 inner.streak = 0;
224 }
225
226 if inner.streak >= self.cfg.window && inner.tokens_consumed >= self.cfg.min_tokens {
227 inner.state = BreakerState::Open;
228 BreakerVerdict::Tripped {
229 delta,
230 streak: inner.streak,
231 tokens_consumed: inner.tokens_consumed,
232 action: self.cfg.action,
233 }
234 } else if inner.streak > 0 {
235 BreakerVerdict::Suspicious {
236 delta,
237 streak: inner.streak,
238 }
239 } else {
240 BreakerVerdict::Progressing { delta }
241 }
242 }
243
244 pub fn release_embedding(&self) {
252 self.inner.lock().recent_embeddings.clear();
253 }
254
255 pub fn reset(&self) {
265 let mut inner = self.inner.lock();
266 inner.streak = 0;
267 inner.state = BreakerState::Closed;
268 inner.recent_embeddings.clear();
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
275
276 use super::*;
277 use crate::embed::HashEmbedder;
278
279 fn cfg() -> BreakerConfig {
280 BreakerConfig {
281 min_tokens: 1000,
282 ..BreakerConfig::default()
283 }
284 }
285
286 #[test]
287 fn verbatim_loop_trips_within_window() {
288 let s = SessionLoopState::new(cfg());
289 let e = HashEmbedder::default();
290 let text = "I should check the database again for the pending orders";
291 assert!(matches!(
293 s.observe(&e, text, 400),
294 BreakerVerdict::Progressing { .. }
295 ));
296 assert!(matches!(
298 s.observe(&e, text, 400),
299 BreakerVerdict::Suspicious { streak: 1, .. }
300 ));
301 assert!(matches!(
302 s.observe(&e, text, 400),
303 BreakerVerdict::Suspicious { streak: 2, .. }
304 ));
305 let v = s.observe(&e, text, 400);
307 assert!(matches!(v, BreakerVerdict::Tripped { streak: 3, .. }), "{v:?}");
308 assert_eq!(s.state(), BreakerState::Open);
309 }
310
311 #[test]
317 fn release_embedding_clears_the_comparison_window() {
318 let s = SessionLoopState::new(cfg());
319 let e = HashEmbedder::default();
320 let text = "repeat the same reasoning step about pending orders";
321 s.observe(&e, text, 100);
322 assert!(matches!(
323 s.observe(&e, text, 100),
324 BreakerVerdict::Suspicious { streak: 1, .. }
325 ));
326 s.release_embedding();
327 let v = s.observe(&e, text, 100);
328 assert!(
329 matches!(v, BreakerVerdict::Progressing { .. }),
330 "released window must not remember prior steps: {v:?}"
331 );
332 }
333
334 #[test]
341 fn delta_exactly_at_epsilon_counts_as_progress() {
342 let s = SessionLoopState::new(BreakerConfig {
343 delta_epsilon: 0.5,
344 ..cfg()
345 });
346 let e = HashEmbedder::default();
347 let embedding = e.embed("first step establishing history");
348 let v = s.observe_embedding_with_similarity(embedding, 100, Some(0.5));
351 assert!(
352 matches!(v, BreakerVerdict::Progressing { .. }),
353 "delta == ε must not grow the streak (strict <): {v:?}"
354 );
355 }
356
357 #[test]
365 fn alternation_window_holds_exactly_eight_embeddings() {
366 let one_hot = |i: usize| {
367 let mut v = vec![0.0f32; 16];
368 if let Some(slot) = v.get_mut(i) {
369 *slot = 1.0;
370 }
371 v
372 };
373 let s = SessionLoopState::new(cfg());
375 for i in 0..9 {
376 s.observe_embedding(one_hot(i), 10);
377 }
378 let v = s.observe_embedding(one_hot(1), 10);
379 assert!(
380 matches!(v, BreakerVerdict::Suspicious { .. }),
381 "#2 must still be in the 8-slot window after 9 observations: {v:?}"
382 );
383 let s = SessionLoopState::new(cfg());
384 for i in 0..9 {
385 s.observe_embedding(one_hot(i), 10);
386 }
387 let v = s.observe_embedding(one_hot(0), 10);
388 assert!(
389 matches!(v, BreakerVerdict::Progressing { .. }),
390 "#1 must have been evicted from the 8-slot window: {v:?}"
391 );
392 }
393
394 #[test]
395 fn token_gate_defers_tripping() {
396 let s = SessionLoopState::new(cfg());
397 let e = HashEmbedder::default();
398 let text = "same thing";
399 for _ in 0..6 {
400 let v = s.observe(&e, text, 10); assert!(
402 !matches!(v, BreakerVerdict::Tripped { .. }),
403 "tripped below the token floor: {v:?}"
404 );
405 }
406 let v = s.observe(&e, text, 2_000);
408 assert!(matches!(v, BreakerVerdict::Tripped { .. }), "{v:?}");
409 }
410
411 #[test]
412 fn progressing_content_never_trips() {
413 let s = SessionLoopState::new(cfg());
414 let e = HashEmbedder::default();
415 let steps = [
416 "Parse the user's CSV upload and validate the header row",
417 "Header valid; now inferring column types from the first 100 rows",
418 "Types inferred: 3 numeric, 2 categorical; building the schema migration",
419 "Migration built; applying to staging and running the smoke tests",
420 "Smoke tests green; generating the summary report for review",
421 "Report ready; posting the artifact link and closing the task",
422 ];
423 for step in steps {
424 let v = s.observe(&e, step, 5_000);
425 assert!(
426 matches!(v, BreakerVerdict::Progressing { .. }),
427 "false positive on progressing step {step:?}: {v:?}"
428 );
429 }
430 assert_eq!(s.state(), BreakerState::Closed);
431 }
432
433 #[test]
434 fn streak_resets_on_progress() {
435 let s = SessionLoopState::new(cfg());
436 let e = HashEmbedder::default();
437 let looped = "retry the same call once more";
438 s.observe(&e, looped, 800);
439 s.observe(&e, looped, 800); s.observe(&e, looped, 800); let v = s.observe(
443 &e,
444 "completely new direction: escalate to the human operator with log excerpts",
445 800,
446 );
447 assert!(matches!(v, BreakerVerdict::Progressing { .. }), "{v:?}");
448 let v = s.observe(&e, looped, 800);
454 assert!(matches!(v, BreakerVerdict::Suspicious { streak: 1, .. }), "{v:?}");
455 }
456
457 #[test]
463 fn simple_alternation_trips_the_breaker() {
464 let s = SessionLoopState::new(cfg());
465 let e = HashEmbedder::default();
466 let a = "check the deployment status of service alpha again";
467 let b = "list the open incidents for service alpha again";
468 let mut tripped = false;
469 for _ in 0..8 {
470 if matches!(s.observe(&e, a, 800), BreakerVerdict::Tripped { .. }) {
471 tripped = true;
472 break;
473 }
474 if matches!(s.observe(&e, b, 800), BreakerVerdict::Tripped { .. }) {
475 tripped = true;
476 break;
477 }
478 }
479 assert!(
480 tripped,
481 "A,B,A,B alternation ran 16 steps without tripping the breaker"
482 );
483 }
484
485 #[test]
490 fn token_floor_survives_reset() {
491 let s = SessionLoopState::new(cfg());
492 let e = HashEmbedder::default();
493 let t = "loop loop loop";
494 for _ in 0..4 {
495 s.observe(&e, t, 500);
496 }
497 assert_eq!(s.state(), BreakerState::Open);
498 s.reset();
499 s.observe(&e, t, 0);
503 s.observe(&e, t, 0);
504 s.observe(&e, t, 0);
505 let v = s.observe(&e, t, 0);
506 assert!(
507 matches!(v, BreakerVerdict::Tripped { .. }),
508 "an agent ignoring the corrective injection must re-trip without re-earning the token floor: {v:?}"
509 );
510 }
511
512 #[test]
513 fn reset_closes_the_breaker() {
514 let s = SessionLoopState::new(cfg());
515 let e = HashEmbedder::default();
516 let t = "loop loop loop";
517 for _ in 0..4 {
518 s.observe(&e, t, 500);
519 }
520 assert_eq!(s.state(), BreakerState::Open);
521 s.reset();
522 assert_eq!(s.state(), BreakerState::Closed);
523 assert!(matches!(
524 s.observe(&e, t, 500),
525 BreakerVerdict::Progressing { .. }
526 ));
527 }
528
529 #[test]
530 fn distributed_similarity_detects_periodic_dag_loop() {
531 let s = SessionLoopState::new(BreakerConfig {
532 window: 2,
533 min_tokens: 0,
534 ..BreakerConfig::default()
535 });
536 assert!(matches!(
537 s.observe_embedding_with_similarity(vec![1.0, 0.0], 10, None),
538 BreakerVerdict::Progressing { .. }
539 ));
540 assert!(matches!(
541 s.observe_embedding_with_similarity(vec![0.0, 1.0], 10, Some(0.99)),
542 BreakerVerdict::Suspicious { streak: 1, .. }
543 ));
544 assert!(matches!(
545 s.observe_embedding_with_similarity(vec![-1.0, 0.0], 10, Some(0.99)),
546 BreakerVerdict::Tripped { streak: 2, .. }
547 ));
548 }
549
550 #[test]
560 fn all_zero_embedding_does_not_defeat_the_breaker() {
561 let s = SessionLoopState::new(BreakerConfig {
562 window: 3,
563 min_tokens: 0,
564 ..BreakerConfig::default()
565 });
566 for _ in 0..2 {
568 let verdict = s.observe_embedding_with_similarity(vec![0.0; 4], 10, None);
569 assert!(
570 matches!(
571 verdict,
572 BreakerVerdict::Suspicious { .. } | BreakerVerdict::Progressing { .. }
573 ),
574 "unexpected verdict during buildup: {verdict:?}"
575 );
576 }
577 let final_verdict = s.observe_embedding_with_similarity(vec![0.0; 4], 10, None);
578 assert!(
579 matches!(final_verdict, BreakerVerdict::Tripped { .. }),
580 "3rd all-zero step must trip the breaker, got {final_verdict:?}"
581 );
582 }
583
584 #[test]
585 fn nan_embedding_does_not_defeat_the_breaker() {
586 let s = SessionLoopState::new(BreakerConfig {
587 window: 2,
588 min_tokens: 0,
589 ..BreakerConfig::default()
590 });
591 let _ = s.observe_embedding_with_similarity(vec![1.0, 0.0], 10, None);
593 let _ = s.observe_embedding_with_similarity(vec![f32::NAN, 0.0], 10, None);
595 let tripped = s.observe_embedding_with_similarity(vec![0.0, f32::NAN], 10, None);
596 assert!(
597 matches!(tripped, BreakerVerdict::Tripped { .. }),
598 "NaN vectors must not slip past the breaker; got {tripped:?}"
599 );
600 }
601}
602
603#[cfg(test)]
604mod similarity_path_tests {
605 #![allow(
606 clippy::unwrap_used,
607 clippy::expect_used,
608 clippy::panic,
609 clippy::indexing_slicing
610 )]
611
612 use super::*;
613
614 #[test]
623 fn near_duplicate_history_similarity_trips_the_breaker() {
624 let cfg = BreakerConfig {
625 min_tokens: 100,
626 ..BreakerConfig::default()
627 };
628 let looping = SessionLoopState::new(cfg.clone());
629 let mut tripped = false;
630 for step in 0..8u32 {
631 let mut e = vec![0.0f32; 16];
634 e[(step as usize) % 16] = 1.0;
635 let verdict = looping.observe_embedding_with_similarity(e, 50, Some(0.999));
636 if matches!(verdict, BreakerVerdict::Tripped { .. }) {
637 tripped = true;
638 break;
639 }
640 }
641 assert!(
642 tripped,
643 "similarity ~1 against history must trip within the window"
644 );
645
646 let progressing = SessionLoopState::new(cfg);
647 for step in 0..8u32 {
648 let mut e = vec![0.0f32; 16];
649 e[(step as usize) % 16] = 1.0;
650 let verdict = progressing.observe_embedding_with_similarity(e, 50, Some(0.01));
651 assert!(
652 !matches!(verdict, BreakerVerdict::Tripped { .. }),
653 "low similarity must not trip"
654 );
655 }
656 }
657
658 #[test]
667 fn paraphrased_rambling_trips_language_independently() {
668 let e = crate::embed::HashEmbedder::default();
669 let mk = || {
670 SessionLoopState::new(BreakerConfig {
671 min_tokens: 1000,
672 ..BreakerConfig::default()
673 })
674 };
675 let cases: [&[&str]; 3] = [
679 &[
680 "I should check the inventory database again for pending orders",
681 "I should check the inventory database again for the pending orders",
682 "I should check the inventory database once again for pending orders",
683 "I really should check the inventory database again for pending orders",
684 ],
685 &[
686 "在庫データベースをもう一度確認して保留中の注文を調べる必要があります",
687 "在庫データベースをもう一度確認して保留中の注文を調べる必要がありますね",
688 "在庫データベースを再度確認して保留中の注文を調べる必要があります",
689 "在庫データベースをもう一度確認して、保留中の注文を調べる必要があります",
690 ],
691 &[
692 "يجب أن أتحقق من قاعدة بيانات المخزون مرة أخرى بحثاً عن الطلبات المعلقة",
693 "يجب أن أتحقق من قاعدة بيانات المخزون مرة أخرى بحثا عن الطلبات المعلقة",
694 "يجب أن أتحقق من قاعدة بيانات المخزون مجدداً بحثاً عن الطلبات المعلقة",
695 "يجب أن أتحقق من قاعدة بيانات المخزون مرة أخرى عن الطلبات المعلقة",
696 ],
697 ];
698 for (i, steps) in cases.iter().enumerate() {
699 let s = mk();
700 let mut tripped = false;
701 for step in *steps {
702 if matches!(s.observe(&e, step, 400), BreakerVerdict::Tripped { .. }) {
703 tripped = true;
704 break;
705 }
706 }
707 assert!(
708 tripped,
709 "case {i}: paraphrased rambling must trip in every language"
710 );
711 }
712 let s = mk();
714 for step in [
715 "在庫データベースを確認しています",
716 "注文番号一二三四の在庫はゼロでした",
717 "コントソ社に八十四ドルの発注書を作成します",
718 "発注が完了しました。担当者に通知します",
719 ] {
720 assert!(
721 !matches!(s.observe(&e, step, 400), BreakerVerdict::Tripped { .. }),
722 "progressing Japanese content must not trip"
723 );
724 }
725 }
726}