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,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum BreakerAction {
34 Reject,
38 Inject,
40 Abort,
42}
43
44impl Default for BreakerConfig {
45 fn default() -> Self {
46 Self {
52 delta_epsilon: 0.30,
53 window: 3,
54 min_tokens: 1_000,
55 action: BreakerAction::Reject,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub enum BreakerVerdict {
63 Progressing {
65 delta: f32,
67 },
68 Suspicious {
70 delta: f32,
72 streak: usize,
74 },
75 Tripped {
77 delta: f32,
79 streak: usize,
81 tokens_consumed: u64,
83 action: BreakerAction,
85 },
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90pub enum BreakerState {
91 Closed,
93 Open,
95}
96
97pub 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 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 pub fn state(&self) -> BreakerState {
131 self.inner.lock().state
132 }
133 pub fn action(&self) -> BreakerAction {
135 self.cfg.action
136 }
137
138 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 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 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 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, }
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 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 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 assert!(matches!(
257 s.observe(&e, text, 400),
258 BreakerVerdict::Progressing { .. }
259 ));
260 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 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); assert!(
283 !matches!(v, BreakerVerdict::Tripped { .. }),
284 "tripped below the token floor: {v:?}"
285 );
286 }
287 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); s.observe(&e, looped, 800); 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 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 #[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 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 let _ = s.observe_embedding_with_similarity(vec![1.0, 0.0], 10, None);
418 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 #[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 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}