1use serde::{Deserialize, Serialize};
19use std::collections::VecDeque;
20
21pub const ENTROPY_WINDOW_TURNS: usize = 8;
23
24const ROLLBACK_SATURATION: f64 = 3.0;
27
28#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct EntropyWatchConfig {
41 #[serde(default)]
42 pub enabled: bool,
43 #[serde(default = "default_threshold")]
45 pub threshold: f64,
46 #[serde(default = "default_hysteresis")]
48 pub hysteresis: f64,
49 #[serde(default = "default_cooldown_turns")]
51 pub cooldown_turns: u32,
52 #[serde(default)]
54 pub notify_model: bool,
55}
56
57fn default_threshold() -> f64 {
58 0.65
59}
60fn default_hysteresis() -> f64 {
61 0.1
62}
63fn default_cooldown_turns() -> u32 {
64 4
65}
66
67impl Default for EntropyWatchConfig {
68 fn default() -> Self {
69 Self {
70 enabled: false,
71 threshold: default_threshold(),
72 hysteresis: default_hysteresis(),
73 cooldown_turns: default_cooldown_turns(),
74 notify_model: false,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct EntropySample {
82 pub turn: u32,
83 pub score: f64,
85 pub rho: f64,
87 pub repeat_pressure: f64,
90 pub failure_rate: f64,
92 pub rollbacks_in_window: u32,
94 pub window_turns: u32,
96}
97
98#[derive(Debug, Default)]
103pub struct EntropyTracker {
104 turn_stats: VecDeque<(u32, u32)>,
106 rollback_stats: VecDeque<u32>,
108 rollbacks_pending: u32,
111 disarmed: bool,
113 last_alert_turn: Option<u32>,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub(crate) struct EntropyTrackerRuntimeState {
123 pub window: Vec<EntropyTurnRuntimeState>,
124 pub rollbacks_pending: u32,
125 pub disarmed: bool,
126 pub last_alert_turn: Option<u32>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub(crate) struct EntropyTurnRuntimeState {
131 pub errored_results: u32,
132 pub total_results: u32,
133 pub rollbacks: u32,
134}
135
136impl EntropyTracker {
137 pub(crate) fn checkpoint_state(&self) -> EntropyTrackerRuntimeState {
138 debug_assert_eq!(
139 self.turn_stats.len(),
140 self.rollback_stats.len(),
141 "entropy outcome and rollback windows advance together"
142 );
143 EntropyTrackerRuntimeState {
144 window: self
145 .turn_stats
146 .iter()
147 .zip(self.rollback_stats.iter())
148 .map(
149 |(&(errored_results, total_results), &rollbacks)| EntropyTurnRuntimeState {
150 errored_results,
151 total_results,
152 rollbacks,
153 },
154 )
155 .collect(),
156 rollbacks_pending: self.rollbacks_pending,
157 disarmed: self.disarmed,
158 last_alert_turn: self.last_alert_turn,
159 }
160 }
161
162 pub(crate) fn restore_state(
163 &mut self,
164 state: EntropyTrackerRuntimeState,
165 current_turn: u32,
166 ) -> Result<(), String> {
167 if state.window.len() > ENTROPY_WINDOW_TURNS {
168 return Err(format!(
169 "entropy window carries {} turns; maximum is {ENTROPY_WINDOW_TURNS}",
170 state.window.len()
171 ));
172 }
173 if let Some(turn) = state.last_alert_turn
174 && turn > current_turn
175 {
176 return Err(format!(
177 "entropy alert turn {turn} is later than scheduler turn {current_turn}"
178 ));
179 }
180 if let Some(invalid) = state
181 .window
182 .iter()
183 .find(|entry| entry.errored_results > entry.total_results)
184 {
185 return Err(format!(
186 "entropy window carries {} errors for only {} results",
187 invalid.errored_results, invalid.total_results
188 ));
189 }
190
191 self.turn_stats = state
192 .window
193 .iter()
194 .map(|entry| (entry.errored_results, entry.total_results))
195 .collect();
196 self.rollback_stats = state.window.iter().map(|entry| entry.rollbacks).collect();
197 self.rollbacks_pending = state.rollbacks_pending;
198 self.disarmed = state.disarmed;
199 self.last_alert_turn = state.last_alert_turn;
200 Ok(())
201 }
202
203 pub fn note_rollback(&mut self) {
205 self.rollbacks_pending += 1;
206 }
207
208 pub fn sample(
211 &mut self,
212 turn: u32,
213 rho: f64,
214 repeat_streak: u32,
215 repeat_deny_after: u32,
216 errored_results: u32,
217 total_results: u32,
218 ) -> EntropySample {
219 self.turn_stats.push_back((errored_results, total_results));
220 self.rollback_stats
221 .push_back(std::mem::take(&mut self.rollbacks_pending));
222 while self.turn_stats.len() > ENTROPY_WINDOW_TURNS {
223 self.turn_stats.pop_front();
224 }
225 while self.rollback_stats.len() > ENTROPY_WINDOW_TURNS {
226 self.rollback_stats.pop_front();
227 }
228
229 let (errors, totals) = self
230 .turn_stats
231 .iter()
232 .fold((0u32, 0u32), |(e, t), (te, tt)| (e + te, t + tt));
233 let rollbacks_in_window: u32 = self.rollback_stats.iter().sum();
234
235 let rho = rho.clamp(0.0, 1.0);
236 let repeat_pressure = (f64::from(repeat_streak.saturating_sub(1))
238 / f64::from(repeat_deny_after.max(1)))
239 .clamp(0.0, 1.0);
240 let failure_rate = if totals == 0 {
241 0.0
242 } else {
243 f64::from(errors) / f64::from(totals)
244 };
245 let rollback_term = (f64::from(rollbacks_in_window) / ROLLBACK_SATURATION).clamp(0.0, 1.0);
246
247 let score =
250 0.35 * repeat_pressure + 0.30 * failure_rate + 0.20 * rho + 0.15 * rollback_term;
251
252 EntropySample {
253 turn,
254 score,
255 rho,
256 repeat_pressure,
257 failure_rate,
258 rollbacks_in_window,
259 window_turns: self.turn_stats.len() as u32,
260 }
261 }
262
263 pub fn should_alert(&mut self, config: &EntropyWatchConfig, sample: &EntropySample) -> bool {
266 if !config.enabled {
267 return false;
268 }
269 if sample.score < config.threshold {
270 if sample.score < config.threshold - config.hysteresis {
271 self.disarmed = false;
272 }
273 return false;
274 }
275 if self.disarmed {
276 return false;
277 }
278 let cooled_down = self
279 .last_alert_turn
280 .is_none_or(|t| sample.turn.saturating_sub(t) >= config.cooldown_turns);
281 if !cooled_down {
282 return false;
283 }
284 self.disarmed = true;
285 self.last_alert_turn = Some(sample.turn);
286 true
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 fn quiet_sample(tracker: &mut EntropyTracker, turn: u32) -> EntropySample {
295 tracker.sample(turn, 0.1, 1, 5, 0, 2)
296 }
297
298 #[test]
299 fn healthy_turn_scores_near_zero() {
300 let mut t = EntropyTracker::default();
301 let s = quiet_sample(&mut t, 1);
302 assert!(
303 s.score < 0.05,
304 "healthy turn score {} should be ~0",
305 s.score
306 );
307 assert_eq!(s.repeat_pressure, 0.0);
308 assert_eq!(s.failure_rate, 0.0);
309 assert_eq!(s.rollbacks_in_window, 0);
310 }
311
312 #[test]
313 fn repetition_and_failures_raise_the_score() {
314 let mut t = EntropyTracker::default();
315 let s = t.sample(3, 0.9, 4, 5, 2, 2);
317 assert!(
318 s.score > 0.6,
319 "disordered turn score {} should be high",
320 s.score
321 );
322 assert!((s.repeat_pressure - 0.6).abs() < 1e-9);
323 assert!((s.failure_rate - 1.0).abs() < 1e-9);
324 }
325
326 #[test]
327 fn failure_rate_windows_out_old_turns() {
328 let mut t = EntropyTracker::default();
329 t.sample(1, 0.1, 1, 5, 3, 3); for turn in 2..=(ENTROPY_WINDOW_TURNS as u32 + 1) {
331 let s = quiet_sample(&mut t, turn);
332 if turn <= ENTROPY_WINDOW_TURNS as u32 {
333 assert!(s.failure_rate > 0.0, "turn {turn} still inside the window");
334 } else {
335 assert_eq!(
336 s.failure_rate, 0.0,
337 "turn {turn} should have evicted the errors"
338 );
339 }
340 }
341 }
342
343 #[test]
344 fn rollbacks_accrue_until_a_boundary_completes() {
345 let mut t = EntropyTracker::default();
346 t.note_rollback();
347 t.note_rollback();
348 let s = quiet_sample(&mut t, 2);
349 assert_eq!(s.rollbacks_in_window, 2);
350 let s = quiet_sample(&mut t, 3);
352 assert_eq!(s.rollbacks_in_window, 2);
353 }
354
355 #[test]
356 fn watch_fires_once_then_rearms_below_hysteresis() {
357 let cfg = EntropyWatchConfig {
358 enabled: true,
359 threshold: 0.5,
360 hysteresis: 0.1,
361 cooldown_turns: 0,
362 notify_model: false,
363 };
364 let mut t = EntropyTracker::default();
365 let hot = EntropySample {
366 turn: 1,
367 score: 0.7,
368 rho: 0.0,
369 repeat_pressure: 0.0,
370 failure_rate: 0.0,
371 rollbacks_in_window: 0,
372 window_turns: 1,
373 };
374 assert!(t.should_alert(&cfg, &hot));
375 assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
377 assert!(!t.should_alert(
379 &cfg,
380 &EntropySample {
381 turn: 3,
382 score: 0.45,
383 ..hot
384 }
385 ));
386 assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
387 assert!(!t.should_alert(
389 &cfg,
390 &EntropySample {
391 turn: 5,
392 score: 0.3,
393 ..hot
394 }
395 ));
396 assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot }));
397 }
398
399 #[test]
400 fn watch_cooldown_gates_refire_even_after_rearm() {
401 let cfg = EntropyWatchConfig {
402 enabled: true,
403 threshold: 0.5,
404 hysteresis: 0.1,
405 cooldown_turns: 5,
406 notify_model: false,
407 };
408 let mut t = EntropyTracker::default();
409 let hot = EntropySample {
410 turn: 1,
411 score: 0.9,
412 rho: 0.0,
413 repeat_pressure: 0.0,
414 failure_rate: 0.0,
415 rollbacks_in_window: 0,
416 window_turns: 1,
417 };
418 assert!(t.should_alert(&cfg, &hot));
419 assert!(!t.should_alert(
420 &cfg,
421 &EntropySample {
422 turn: 2,
423 score: 0.2,
424 ..hot
425 }
426 )); assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); }
430
431 #[test]
432 fn watch_disabled_never_alerts() {
433 let cfg = EntropyWatchConfig::default();
434 assert!(!cfg.enabled);
435 let mut t = EntropyTracker::default();
436 let hot = EntropySample {
437 turn: 1,
438 score: 1.0,
439 rho: 1.0,
440 repeat_pressure: 1.0,
441 failure_rate: 1.0,
442 rollbacks_in_window: 9,
443 window_turns: 8,
444 };
445 assert!(!t.should_alert(&cfg, &hot));
446 }
447}