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 429), `inject` (corrective system
24    /// payload), or `abort` (connection abort) — the enforceable equivalents
25    /// of the brief's TCP RST / 429 / corrective-payload triple.
26    pub action: BreakerAction,
27}
28
29/// What the harness does when the breaker trips.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum BreakerAction {
34    /// Respond with a permanent policy refusal (HTTP 403 in the harness;
35    /// deliberately not 429, which mainstream SDKs auto-retry — see
36    /// `PipelineError::status`).
37    Reject,
38    /// Inject a corrective system payload into the conversation.
39    Inject,
40    /// Abort the connection.
41    Abort,
42}
43
44impl Default for BreakerConfig {
45    fn default() -> Self {
46        // ε calibrated against measured HashEmbedder delta distributions
47        // (tests/calibrate.rs): paraphrase-loop steps score Δ ≈ 0.13–0.18,
48        // genuinely progressing steps Δ ≈ 0.88–0.98. 0.30 sits between with
49        // wide margins on both sides (semantic ONNX embedders push loop
50        // deltas even lower, so the margin only grows with better models).
51        Self {
52            delta_epsilon: 0.30,
53            window: 3,
54            min_tokens: 1_000,
55            action: BreakerAction::Reject,
56        }
57    }
58}
59
60/// Verdict for one observed step.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub enum BreakerVerdict {
63    /// Session is making progress.
64    Progressing {
65        /// Semantic delta vs the previous step (1 − cosine).
66        delta: f32,
67    },
68    /// Near-zero progress but the window/token gate hasn't tripped yet.
69    Suspicious {
70        /// Semantic delta vs the previous step.
71        delta: f32,
72        /// Consecutive near-zero steps so far.
73        streak: usize,
74    },
75    /// Loop detected — enforce now.
76    Tripped {
77        /// Semantic delta vs the previous step.
78        delta: f32,
79        /// Consecutive near-zero steps that tripped the breaker.
80        streak: usize,
81        /// Session tokens consumed at trip time.
82        tokens_consumed: u64,
83        /// Configured action.
84        action: BreakerAction,
85    },
86}
87
88/// Current breaker state (exposed to metrics / events).
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90pub enum BreakerState {
91    /// Not tripped.
92    Closed,
93    /// Tripped: session enforcement active.
94    Open,
95}
96
97/// Per-session loop-detection state. Embedding/delta mutation happens on
98/// worker threads; the hot path reads [`SessionLoopState::state`] and
99/// [`SessionLoopState::action`], and calls [`SessionLoopState::reset`] when
100/// an Inject verdict fires.
101pub struct SessionLoopState {
102    cfg: BreakerConfig,
103    inner: Mutex<Inner>,
104}
105
106struct Inner {
107    prev_embedding: Option<Vec<f32>>,
108    deltas: VecDeque<f32>,
109    streak: usize,
110    tokens_consumed: u64,
111    state: BreakerState,
112}
113
114impl SessionLoopState {
115    /// Create with `cfg`.
116    pub fn new(cfg: BreakerConfig) -> Self {
117        Self {
118            cfg,
119            inner: Mutex::new(Inner {
120                prev_embedding: None,
121                deltas: VecDeque::with_capacity(16),
122                streak: 0,
123                tokens_consumed: 0,
124                state: BreakerState::Closed,
125            }),
126        }
127    }
128
129    /// Current breaker state (hot-path read).
130    pub fn state(&self) -> BreakerState {
131        self.inner.lock().state
132    }
133    /// Configured enforcement action when the breaker is open.
134    pub fn action(&self) -> BreakerAction {
135        self.cfg.action
136    }
137
138    /// Observe one reasoning step (worker thread): embed, compute Δ against
139    /// the previous step, update the streak, return the verdict.
140    pub fn observe(&self, embedder: &dyn Embedder, text: &str, step_tokens: u64) -> BreakerVerdict {
141        let embedding = embedder.embed(text);
142        self.observe_embedding(embedding, step_tokens)
143    }
144
145    /// Observe a precomputed embedding. Workers use this path when the same
146    /// vector is also persisted to an off-path vector sink.
147    pub fn observe_embedding(&self, embedding: Vec<f32>, step_tokens: u64) -> BreakerVerdict {
148        self.observe_embedding_with_similarity(embedding, step_tokens, None)
149    }
150
151    /// Observe an embedding plus an optional nearest prior-step similarity
152    /// (scoped to the same session) supplied by a distributed vector engine.
153    pub fn observe_embedding_with_similarity(
154        &self,
155        embedding: Vec<f32>,
156        step_tokens: u64,
157        nearest_similarity: Option<f32>,
158    ) -> BreakerVerdict {
159        let mut inner = self.inner.lock();
160        inner.tokens_consumed = inner.tokens_consumed.saturating_add(step_tokens);
161
162        // Fail-closed on non-finite or all-zero embeddings: a hostile
163        // or misconfigured embedder can produce NaN/Inf vectors (any
164        // arithmetic through `cosine`'s clamp path would return NaN,
165        // and NaN < delta_epsilon is false, so the streak resets on
166        // every step and the breaker never trips). An all-zero vector
167        // is the ONNX embedder's error fallback — treating it as
168        // maximum novelty lets a client with malformed prompts drive
169        // the breaker into "novel-forever" mode. In both cases, treat
170        // the step as a duplicate of the previous one (delta = 0),
171        // which conservatively grows the streak.
172        let embedding_is_finite = embedding.iter().all(|x| x.is_finite());
173        let embedding_is_all_zero = embedding.iter().all(|x| *x == 0.0);
174        let embedding_is_hostile = !embedding_is_finite || embedding_is_all_zero;
175
176        let adjacent_delta = if embedding_is_hostile {
177            0.0
178        } else {
179            match &inner.prev_embedding {
180                Some(prev) => 1.0 - cosine(prev, &embedding),
181                None => 1.0, // first step: maximum novelty by definition
182            }
183        };
184        let delta = nearest_similarity
185            .filter(|similarity| similarity.is_finite())
186            .map_or(adjacent_delta, |similarity| {
187                adjacent_delta.min(1.0 - similarity.clamp(-1.0, 1.0))
188            });
189        // Only record the embedding for future adjacent comparisons
190        // when it's usable — a stored NaN vector would poison every
191        // subsequent step's cosine.
192        if !embedding_is_hostile {
193            inner.prev_embedding = Some(embedding);
194        }
195        inner.deltas.push_back(delta);
196        if inner.deltas.len() > 32 {
197            inner.deltas.pop_front();
198        }
199
200        if delta < self.cfg.delta_epsilon {
201            inner.streak += 1;
202        } else {
203            inner.streak = 0;
204        }
205
206        if inner.streak >= self.cfg.window && inner.tokens_consumed >= self.cfg.min_tokens {
207            inner.state = BreakerState::Open;
208            BreakerVerdict::Tripped {
209                delta,
210                streak: inner.streak,
211                tokens_consumed: inner.tokens_consumed,
212                action: self.cfg.action,
213            }
214        } else if inner.streak > 0 {
215            BreakerVerdict::Suspicious {
216                delta,
217                streak: inner.streak,
218            }
219        } else {
220            BreakerVerdict::Progressing { delta }
221        }
222    }
223
224    /// Manually reset (e.g. after a corrective injection gave the agent a new
225    /// direction). Clears the streak, the token floor, and closes the breaker.
226    pub fn reset(&self) {
227        let mut inner = self.inner.lock();
228        inner.streak = 0;
229        inner.state = BreakerState::Closed;
230        inner.prev_embedding = None;
231        inner.deltas.clear();
232        inner.tokens_consumed = 0;
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
239
240    use super::*;
241    use crate::embed::HashEmbedder;
242
243    fn cfg() -> BreakerConfig {
244        BreakerConfig {
245            min_tokens: 1000,
246            ..BreakerConfig::default()
247        }
248    }
249
250    #[test]
251    fn verbatim_loop_trips_within_window() {
252        let s = SessionLoopState::new(cfg());
253        let e = HashEmbedder::default();
254        let text = "I should check the database again for the pending orders";
255        // Step 1 establishes the baseline (delta = 1.0).
256        assert!(matches!(
257            s.observe(&e, text, 400),
258            BreakerVerdict::Progressing { .. }
259        ));
260        // Steps 2-3: identical → streak builds but token gate also applies.
261        assert!(matches!(
262            s.observe(&e, text, 400),
263            BreakerVerdict::Suspicious { streak: 1, .. }
264        ));
265        assert!(matches!(
266            s.observe(&e, text, 400),
267            BreakerVerdict::Suspicious { streak: 2, .. }
268        ));
269        // Step 4: streak 3 + 1600 tokens ≥ 1000 → tripped (≤ 3 cycles after baseline).
270        let v = s.observe(&e, text, 400);
271        assert!(matches!(v, BreakerVerdict::Tripped { streak: 3, .. }), "{v:?}");
272        assert_eq!(s.state(), BreakerState::Open);
273    }
274
275    #[test]
276    fn token_gate_defers_tripping() {
277        let s = SessionLoopState::new(cfg());
278        let e = HashEmbedder::default();
279        let text = "same thing";
280        for _ in 0..6 {
281            let v = s.observe(&e, text, 10); // tiny token flow
282            assert!(
283                !matches!(v, BreakerVerdict::Tripped { .. }),
284                "tripped below the token floor: {v:?}"
285            );
286        }
287        // Once tokens cross the floor, the standing streak trips immediately.
288        let v = s.observe(&e, text, 2_000);
289        assert!(matches!(v, BreakerVerdict::Tripped { .. }), "{v:?}");
290    }
291
292    #[test]
293    fn progressing_content_never_trips() {
294        let s = SessionLoopState::new(cfg());
295        let e = HashEmbedder::default();
296        let steps = [
297            "Parse the user's CSV upload and validate the header row",
298            "Header valid; now inferring column types from the first 100 rows",
299            "Types inferred: 3 numeric, 2 categorical; building the schema migration",
300            "Migration built; applying to staging and running the smoke tests",
301            "Smoke tests green; generating the summary report for review",
302            "Report ready; posting the artifact link and closing the task",
303        ];
304        for step in steps {
305            let v = s.observe(&e, step, 5_000);
306            assert!(
307                matches!(v, BreakerVerdict::Progressing { .. }),
308                "false positive on progressing step {step:?}: {v:?}"
309            );
310        }
311        assert_eq!(s.state(), BreakerState::Closed);
312    }
313
314    #[test]
315    fn streak_resets_on_progress() {
316        let s = SessionLoopState::new(cfg());
317        let e = HashEmbedder::default();
318        let looped = "retry the same call once more";
319        s.observe(&e, looped, 800);
320        s.observe(&e, looped, 800); // streak 1
321        s.observe(&e, looped, 800); // streak 2
322                                    // Progress breaks the streak before it reaches 3.
323        let v = s.observe(
324            &e,
325            "completely new direction: escalate to the human operator with log excerpts",
326            800,
327        );
328        assert!(matches!(v, BreakerVerdict::Progressing { .. }), "{v:?}");
329        // Next looped step compares against the *progress* text → still novel
330        // (streak 0); only the one after that restarts the streak at 1.
331        let v = s.observe(&e, looped, 800);
332        assert!(matches!(v, BreakerVerdict::Progressing { .. }), "{v:?}");
333        let v = s.observe(&e, looped, 800);
334        assert!(matches!(v, BreakerVerdict::Suspicious { streak: 1, .. }), "{v:?}");
335    }
336
337    #[test]
338    fn reset_closes_the_breaker() {
339        let s = SessionLoopState::new(cfg());
340        let e = HashEmbedder::default();
341        let t = "loop loop loop";
342        for _ in 0..4 {
343            s.observe(&e, t, 500);
344        }
345        assert_eq!(s.state(), BreakerState::Open);
346        s.reset();
347        assert_eq!(s.state(), BreakerState::Closed);
348        assert!(matches!(
349            s.observe(&e, t, 500),
350            BreakerVerdict::Progressing { .. }
351        ));
352    }
353
354    #[test]
355    fn distributed_similarity_detects_periodic_dag_loop() {
356        let s = SessionLoopState::new(BreakerConfig {
357            window: 2,
358            min_tokens: 0,
359            ..BreakerConfig::default()
360        });
361        assert!(matches!(
362            s.observe_embedding_with_similarity(vec![1.0, 0.0], 10, None),
363            BreakerVerdict::Progressing { .. }
364        ));
365        assert!(matches!(
366            s.observe_embedding_with_similarity(vec![0.0, 1.0], 10, Some(0.99)),
367            BreakerVerdict::Suspicious { streak: 1, .. }
368        ));
369        assert!(matches!(
370            s.observe_embedding_with_similarity(vec![-1.0, 0.0], 10, Some(0.99)),
371            BreakerVerdict::Tripped { streak: 2, .. }
372        ));
373    }
374
375    /// A hostile / misconfigured embedder that returns all-zero vectors
376    /// (the ONNX embedder's error fallback) or NaN/Inf vectors must not
377    /// let a caller drive the breaker into "novel-forever" mode.
378    /// Zero-vector adjacent delta is `1.0 - cosine(0, 0)`; `cosine`
379    /// short-circuits `0.0` on either magnitude, so the raw delta is
380    /// `1.0` — full novelty on every step, streak never grows, breaker
381    /// never trips. NaN vectors take the clamp path and produce NaN
382    /// deltas which compare false against `delta_epsilon`, same effect.
383    /// Fail-closed: treat both as maximum suspicion (delta = 0).
384    #[test]
385    fn all_zero_embedding_does_not_defeat_the_breaker() {
386        let s = SessionLoopState::new(BreakerConfig {
387            window: 3,
388            min_tokens: 0,
389            ..BreakerConfig::default()
390        });
391        // Three consecutive all-zero steps must trip, not walk indefinitely.
392        for _ in 0..2 {
393            let verdict = s.observe_embedding_with_similarity(vec![0.0; 4], 10, None);
394            assert!(
395                matches!(
396                    verdict,
397                    BreakerVerdict::Suspicious { .. } | BreakerVerdict::Progressing { .. }
398                ),
399                "unexpected verdict during buildup: {verdict:?}"
400            );
401        }
402        let final_verdict = s.observe_embedding_with_similarity(vec![0.0; 4], 10, None);
403        assert!(
404            matches!(final_verdict, BreakerVerdict::Tripped { .. }),
405            "3rd all-zero step must trip the breaker, got {final_verdict:?}"
406        );
407    }
408
409    #[test]
410    fn nan_embedding_does_not_defeat_the_breaker() {
411        let s = SessionLoopState::new(BreakerConfig {
412            window: 2,
413            min_tokens: 0,
414            ..BreakerConfig::default()
415        });
416        // First step establishes prev_embedding.
417        let _ = s.observe_embedding_with_similarity(vec![1.0, 0.0], 10, None);
418        // Two NaN steps: treated as duplicate (delta = 0), streak grows.
419        let _ = s.observe_embedding_with_similarity(vec![f32::NAN, 0.0], 10, None);
420        let tripped = s.observe_embedding_with_similarity(vec![0.0, f32::NAN], 10, None);
421        assert!(
422            matches!(tripped, BreakerVerdict::Tripped { .. }),
423            "NaN vectors must not slip past the breaker; got {tripped:?}"
424        );
425    }
426}
427
428#[cfg(test)]
429mod similarity_path_tests {
430    #![allow(
431        clippy::unwrap_used,
432        clippy::expect_used,
433        clippy::panic,
434        clippy::indexing_slicing
435    )]
436
437    use super::*;
438
439    /// Mutation-run hardening (round 12): the Qdrant-similarity arm
440    /// (`1.0 - similarity`) had no direct test — a mutant deleting the
441    /// subtraction turns "nearly identical to history" (similarity ~1,
442    /// delta ~0) into "maximum novelty" (delta ~1) and the breaker
443    /// never trips through the vector-store path. Feed orthogonal
444    /// adjacent embeddings (adjacent delta = 1.0: never trips) with
445    /// near-1 nearest_similarity and require the trip; then prove
446    /// near-0 similarity does NOT trip.
447    #[test]
448    fn near_duplicate_history_similarity_trips_the_breaker() {
449        let cfg = BreakerConfig {
450            min_tokens: 100,
451            ..BreakerConfig::default()
452        };
453        let looping = SessionLoopState::new(cfg.clone());
454        let mut tripped = false;
455        for step in 0..8u32 {
456            // Orthogonal one-hot embeddings: adjacent cosine = 0, so
457            // only the similarity path can produce a small delta.
458            let mut e = vec![0.0f32; 16];
459            e[(step as usize) % 16] = 1.0;
460            let verdict = looping.observe_embedding_with_similarity(e, 50, Some(0.999));
461            if matches!(verdict, BreakerVerdict::Tripped { .. }) {
462                tripped = true;
463                break;
464            }
465        }
466        assert!(
467            tripped,
468            "similarity ~1 against history must trip within the window"
469        );
470
471        let progressing = SessionLoopState::new(cfg);
472        for step in 0..8u32 {
473            let mut e = vec![0.0f32; 16];
474            e[(step as usize) % 16] = 1.0;
475            let verdict = progressing.observe_embedding_with_similarity(e, 50, Some(0.01));
476            assert!(
477                !matches!(verdict, BreakerVerdict::Tripped { .. }),
478                "low similarity must not trip"
479            );
480        }
481    }
482}