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