Skip to main content

av_loopdetect/
breaker.rs

1//! The circuit breaker: per-session Δ window + token gate + verdicts.
2
3use crate::embed::{cosine, Embedder};
4use parking_lot::Mutex;
5use serde::{Deserialize, Serialize};
6use std::collections::VecDeque;
7
8/// Breaker tuning (config-file surface). Defaults implement the brief's rule:
9/// Δ≈0 across 3 consecutive steps while consuming N+ tokens.
10///
11/// Unknown keys are rejected so `[breaker]` typos fail loudly at startup.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct BreakerConfig {
15    /// Δ threshold: a step with `1 - cosine < delta_epsilon` counts as
16    /// near-zero semantic progress.
17    pub delta_epsilon: f32,
18    /// Consecutive near-zero steps required to trip.
19    pub window: usize,
20    /// Minimum session token consumption before the breaker may trip
21    /// (prevents tripping on short legitimate confirmations).
22    pub min_tokens: u64,
23    /// Action on trip: `reject` (HTTP 403 — deliberately not the brief's
24    /// 429, which SDKs auto-retry; see `PipelineError::status`), `inject`
25    /// (corrective system
26    /// payload), or `abort` (connection abort) — the enforceable equivalents
27    /// of the brief's TCP RST / 429 / corrective-payload triple.
28    pub action: BreakerAction,
29}
30
31/// What the harness does when the breaker trips.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum BreakerAction {
36    /// Respond with a permanent policy refusal (HTTP 403 in the harness;
37    /// deliberately not 429, which mainstream SDKs auto-retry — see
38    /// `PipelineError::status`).
39    Reject,
40    /// Inject a corrective system payload into the conversation.
41    Inject,
42    /// Abort the connection.
43    Abort,
44}
45
46impl Default for BreakerConfig {
47    fn default() -> Self {
48        // ε calibrated against measured HashEmbedder delta distributions
49        // (tests/calibrate.rs): paraphrase-loop steps score Δ ≈ 0.13–0.18,
50        // genuinely progressing steps Δ ≈ 0.88–0.98. 0.30 sits between with
51        // wide margins on both sides (semantic ONNX embedders push loop
52        // deltas even lower, so the margin only grows with better models).
53        Self {
54            delta_epsilon: 0.30,
55            window: 3,
56            min_tokens: 1_000,
57            action: BreakerAction::Reject,
58        }
59    }
60}
61
62/// Verdict for one observed step.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub enum BreakerVerdict {
66    /// Session is making progress.
67    Progressing {
68        /// Semantic delta vs the previous step (1 − cosine).
69        delta: f32,
70    },
71    /// Near-zero progress but the window/token gate hasn't tripped yet.
72    Suspicious {
73        /// Semantic delta vs the previous step.
74        delta: f32,
75        /// Consecutive near-zero steps so far.
76        streak: usize,
77    },
78    /// Loop detected — enforce now.
79    Tripped {
80        /// Semantic delta vs the previous step.
81        delta: f32,
82        /// Consecutive near-zero steps that tripped the breaker.
83        streak: usize,
84        /// Session tokens consumed at trip time.
85        tokens_consumed: u64,
86        /// Configured action.
87        action: BreakerAction,
88    },
89}
90
91/// Current breaker state (exposed to metrics / events).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub enum BreakerState {
95    /// Not tripped.
96    Closed,
97    /// Tripped: session enforcement active.
98    Open,
99}
100
101/// Per-session loop-detection state. Embedding/delta mutation happens on
102/// worker threads; the hot path reads [`SessionLoopState::state`] and
103/// [`SessionLoopState::action`], and calls [`SessionLoopState::reset`] when
104/// an Inject verdict fires.
105pub struct SessionLoopState {
106    cfg: BreakerConfig,
107    inner: Mutex<Inner>,
108}
109
110struct Inner {
111    /// Ring of the most recent usable embeddings. A window (not a
112    /// single slot) is required: a breaker that compares only against
113    /// the IMMEDIATELY preceding embedding lets simple alternation
114    /// (A, B, A, B, …) —
115    /// the canonical agent-loop shape — run forever in the default
116    /// build (cross-history tightening needed a Qdrant sink). The
117    /// delta is now the minimum against any embedding in this window,
118    /// so a step that duplicates ANY recent step grows the streak.
119    recent_embeddings: VecDeque<Vec<f32>>,
120    streak: usize,
121    tokens_consumed: u64,
122    state: BreakerState,
123}
124
125/// How many recent embeddings the alternation window retains. Eight
126/// covers alternation periods up to 8 (A,B,…,H,A,…) while keeping
127/// per-session memory bounded (8 × dim × 4 bytes ≈ 12 KiB at
128/// MiniLM's 384 dims).
129const RECENT_EMBEDDING_WINDOW: usize = 8;
130
131impl SessionLoopState {
132    /// Create with `cfg`.
133    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    /// Current breaker state (hot-path read).
146    pub fn state(&self) -> BreakerState {
147        self.inner.lock().state
148    }
149    /// Configured enforcement action when the breaker is open.
150    pub fn action(&self) -> BreakerAction {
151        self.cfg.action
152    }
153
154    /// Observe one reasoning step (worker thread): embed, compute Δ against
155    /// the previous step, update the streak, return the verdict.
156    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    /// Observe a precomputed embedding. Workers use this path when the same
162    /// vector is also persisted to an off-path vector sink.
163    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    /// Observe an embedding plus an optional nearest prior-step similarity
168    /// (scoped to the same session) supplied by a distributed vector engine.
169    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        // Fail-closed on non-finite or all-zero embeddings: a hostile
179        // or misconfigured embedder can produce NaN/Inf vectors (any
180        // arithmetic through `cosine`'s clamp path would return NaN,
181        // and NaN < delta_epsilon is false, so the streak resets on
182        // every step and the breaker never trips). An all-zero vector
183        // is the ONNX embedder's error fallback — treating it as
184        // maximum novelty lets a client with malformed prompts drive
185        // the breaker into "novel-forever" mode. In both cases, treat
186        // the step as a duplicate of the previous one (delta = 0),
187        // which conservatively grows the streak.
188        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 // first step: maximum novelty by definition
196        } else {
197            // Minimum delta against ANY recent embedding — catches
198            // A,B,A,B alternation, not only exact-adjacent repeats.
199            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        // Only record the embedding for future comparisons when it's
211        // usable — a stored NaN vector would poison every subsequent
212        // step's cosine.
213        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    /// Release the retained embedding buffers.
245    ///
246    /// Unsigned sessions stay registered for the process lifetime by
247    /// design, and each one otherwise pins its recent response
248    /// embeddings (window × dim × f32) forever — dead weight after
249    /// close, since a closed session never calls `observe` again.
250    /// Unlike [`Self::reset`], the breaker verdict state is preserved.
251    pub fn release_embedding(&self) {
252        self.inner.lock().recent_embeddings.clear();
253    }
254
255    /// Manually reset (e.g. after a corrective injection gave the agent a new
256    /// direction). Clears the streak and the embedding window and closes the
257    /// breaker.
258    ///
259    /// `tokens_consumed` deliberately SURVIVES the reset.
260    /// It implements the documented "minimum session token consumption"
261    /// floor — zeroing it on every Inject turned the session-lifetime
262    /// floor into a per-cycle floor, letting an agent that ignores the
263    /// corrective message loop forever at ~min_tokens per correction.
264    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        // Step 1 establishes the baseline (delta = 1.0).
292        assert!(matches!(
293            s.observe(&e, text, 400),
294            BreakerVerdict::Progressing { .. }
295        ));
296        // Steps 2-3: identical → streak builds but token gate also applies.
297        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        // Step 4: streak 3 + 1600 tokens ≥ 1000 → tripped (≤ 3 cycles after baseline).
306        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    /// Mutation-run hardening: `release_embedding` must actually clear the
312    /// retained window (a no-op mutant survived). Observable behavior: an
313    /// identical step right after a release compares against an EMPTY
314    /// history — maximum novelty, streak reset — where the un-released
315    /// window would have scored it a near-zero delta and kept the streak.
316    #[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    /// Mutation-run hardening (round 8): the streak comparison is
335    /// STRICT (`delta < delta_epsilon`) — a step whose delta lands
336    /// exactly ON the threshold counts as progress, not suspicion. A
337    /// surviving `<`→`<=` mutant showed the boundary unpinned. Driven
338    /// through the distributed-similarity path so the delta is an
339    /// exactly-representable float (1.0 − 0.5).
340    #[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        // First observation: empty history, adjacent delta 1.0; the
349        // sink-supplied similarity 0.5 gives delta exactly 0.5 == ε.
350        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    /// Mutation-run hardening (round 8): the alternation window holds
358    /// EXACTLY `RECENT_EMBEDDING_WINDOW` (8) embeddings — surviving
359    /// `>`/`>=`/`==` mutants on the pop condition showed the size
360    /// unpinned in both directions. With one-hot embeddings: after
361    /// nine observations the window holds #2..=#9, so a repeat of #2
362    /// scores delta 0 (suspicious) while a repeat of #1 scores delta
363    /// 1.0 (evicted — progressing).
364    #[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        // Window edge: after 9 pushes, #1 (index 0) is evicted, #2 kept.
374        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); // tiny token flow
401            assert!(
402                !matches!(v, BreakerVerdict::Tripped { .. }),
403                "tripped below the token floor: {v:?}"
404            );
405        }
406        // Once tokens cross the floor, the standing streak trips immediately.
407        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); // streak 1
440        s.observe(&e, looped, 800); // streak 2
441                                    // Progress breaks the streak before it reaches 3.
442        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        // A looped step after ONE progress interjection
449        // still matches the earlier repeats in the embedding window —
450        // the streak resumes immediately instead of being laundered by
451        // an A,A,B,A alternation (the old adjacent-only comparison
452        // treated the return to A as novel).
453        let v = s.observe(&e, looped, 800);
454        assert!(matches!(v, BreakerVerdict::Suspicious { streak: 1, .. }), "{v:?}");
455    }
456
457    /// Simple alternation — A, B, A, B, … — is the
458    /// canonical agent-loop shape and previously NEVER tripped the
459    /// breaker in the default build (only the immediately-preceding
460    /// embedding was compared; the cross-history path needed a Qdrant
461    /// sink). The recent-embedding window must catch it.
462    #[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    /// `tokens_consumed` is a SESSION floor, not a
486    /// per-cycle floor. After an Inject-triggered reset, an agent that
487    /// ignores the corrective message must re-trip without having to
488    /// burn `min_tokens` again from zero.
489    #[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        // Post-reset: the window is cleared so the first step is novel,
500        // but the token floor is already satisfied — the next streak of
501        // `window` duplicates trips WITHOUT further token accumulation.
502        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    /// A hostile / misconfigured embedder that returns all-zero vectors
551    /// (the ONNX embedder's error fallback) or NaN/Inf vectors must not
552    /// let a caller drive the breaker into "novel-forever" mode.
553    /// Zero-vector adjacent delta is `1.0 - cosine(0, 0)`; `cosine`
554    /// short-circuits `0.0` on either magnitude, so the raw delta is
555    /// `1.0` — full novelty on every step, streak never grows, breaker
556    /// never trips. NaN vectors take the clamp path and produce NaN
557    /// deltas which compare false against `delta_epsilon`, same effect.
558    /// Fail-closed: treat both as maximum suspicion (delta = 0).
559    #[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        // Three consecutive all-zero steps must trip, not walk indefinitely.
567        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        // First step establishes prev_embedding.
592        let _ = s.observe_embedding_with_similarity(vec![1.0, 0.0], 10, None);
593        // Two NaN steps: treated as duplicate (delta = 0), streak grows.
594        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    /// Mutation-run hardening: the Qdrant-similarity arm
615    /// (`1.0 - similarity`) had no direct test — a mutant deleting the
616    /// subtraction turns "nearly identical to history" (similarity ~1,
617    /// delta ~0) into "maximum novelty" (delta ~1) and the breaker
618    /// never trips through the vector-store path. Feed orthogonal
619    /// adjacent embeddings (adjacent delta = 1.0: never trips) with
620    /// near-1 nearest_similarity and require the trip; then prove
621    /// near-0 similarity does NOT trip.
622    #[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            // Orthogonal one-hot embeddings: adjacent cosine = 0, so
632            // only the similarity path can produce a small delta.
633            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    /// Round-120 language parity: the anti-rambling breaker must work
659    /// for agents that don't speak English. The char-n-gram embedder is
660    /// whitespace-independent by design — paraphrased rambling in
661    /// Japanese (no word boundaries) and Arabic must build a streak and
662    /// trip exactly like English, and genuinely progressing CJK content
663    /// must NOT trip. Guards against a future "smarter" tokenizer
664    /// (e.g. split_whitespace) silently disabling the breaker for the
665    /// half of the world that doesn't delimit words with spaces.
666    #[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        // Near-paraphrases: same core sentence, small tail variations —
676        // the realistic rambling shape (counters/qualifiers change,
677        // content doesn't).
678        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        // Control: genuinely progressing Japanese content must stay open.
713        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}