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