deepstrike_core/scheduler/
entropy.rs1use 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
117impl EntropyTracker {
118 pub fn note_rollback(&mut self) {
120 self.rollbacks_pending += 1;
121 }
122
123 pub fn sample(
126 &mut self,
127 turn: u32,
128 rho: f64,
129 repeat_streak: u32,
130 repeat_deny_after: u32,
131 errored_results: u32,
132 total_results: u32,
133 ) -> EntropySample {
134 self.turn_stats.push_back((errored_results, total_results));
135 self.rollback_stats
136 .push_back(std::mem::take(&mut self.rollbacks_pending));
137 while self.turn_stats.len() > ENTROPY_WINDOW_TURNS {
138 self.turn_stats.pop_front();
139 }
140 while self.rollback_stats.len() > ENTROPY_WINDOW_TURNS {
141 self.rollback_stats.pop_front();
142 }
143
144 let (errors, totals) = self
145 .turn_stats
146 .iter()
147 .fold((0u32, 0u32), |(e, t), (te, tt)| (e + te, t + tt));
148 let rollbacks_in_window: u32 = self.rollback_stats.iter().sum();
149
150 let rho = rho.clamp(0.0, 1.0);
151 let repeat_pressure = (f64::from(repeat_streak.saturating_sub(1))
153 / f64::from(repeat_deny_after.max(1)))
154 .clamp(0.0, 1.0);
155 let failure_rate = if totals == 0 {
156 0.0
157 } else {
158 f64::from(errors) / f64::from(totals)
159 };
160 let rollback_term = (f64::from(rollbacks_in_window) / ROLLBACK_SATURATION).clamp(0.0, 1.0);
161
162 let score =
165 0.35 * repeat_pressure + 0.30 * failure_rate + 0.20 * rho + 0.15 * rollback_term;
166
167 EntropySample {
168 turn,
169 score,
170 rho,
171 repeat_pressure,
172 failure_rate,
173 rollbacks_in_window,
174 window_turns: self.turn_stats.len() as u32,
175 }
176 }
177
178 pub fn should_alert(&mut self, config: &EntropyWatchConfig, sample: &EntropySample) -> bool {
181 if !config.enabled {
182 return false;
183 }
184 if sample.score < config.threshold {
185 if sample.score < config.threshold - config.hysteresis {
186 self.disarmed = false;
187 }
188 return false;
189 }
190 if self.disarmed {
191 return false;
192 }
193 let cooled_down = self
194 .last_alert_turn
195 .is_none_or(|t| sample.turn.saturating_sub(t) >= config.cooldown_turns);
196 if !cooled_down {
197 return false;
198 }
199 self.disarmed = true;
200 self.last_alert_turn = Some(sample.turn);
201 true
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn quiet_sample(tracker: &mut EntropyTracker, turn: u32) -> EntropySample {
210 tracker.sample(turn, 0.1, 1, 5, 0, 2)
211 }
212
213 #[test]
214 fn healthy_turn_scores_near_zero() {
215 let mut t = EntropyTracker::default();
216 let s = quiet_sample(&mut t, 1);
217 assert!(
218 s.score < 0.05,
219 "healthy turn score {} should be ~0",
220 s.score
221 );
222 assert_eq!(s.repeat_pressure, 0.0);
223 assert_eq!(s.failure_rate, 0.0);
224 assert_eq!(s.rollbacks_in_window, 0);
225 }
226
227 #[test]
228 fn repetition_and_failures_raise_the_score() {
229 let mut t = EntropyTracker::default();
230 let s = t.sample(3, 0.9, 4, 5, 2, 2);
232 assert!(
233 s.score > 0.6,
234 "disordered turn score {} should be high",
235 s.score
236 );
237 assert!((s.repeat_pressure - 0.6).abs() < 1e-9);
238 assert!((s.failure_rate - 1.0).abs() < 1e-9);
239 }
240
241 #[test]
242 fn failure_rate_windows_out_old_turns() {
243 let mut t = EntropyTracker::default();
244 t.sample(1, 0.1, 1, 5, 3, 3); for turn in 2..=(ENTROPY_WINDOW_TURNS as u32 + 1) {
246 let s = quiet_sample(&mut t, turn);
247 if turn <= ENTROPY_WINDOW_TURNS as u32 {
248 assert!(s.failure_rate > 0.0, "turn {turn} still inside the window");
249 } else {
250 assert_eq!(
251 s.failure_rate, 0.0,
252 "turn {turn} should have evicted the errors"
253 );
254 }
255 }
256 }
257
258 #[test]
259 fn rollbacks_accrue_until_a_boundary_completes() {
260 let mut t = EntropyTracker::default();
261 t.note_rollback();
262 t.note_rollback();
263 let s = quiet_sample(&mut t, 2);
264 assert_eq!(s.rollbacks_in_window, 2);
265 let s = quiet_sample(&mut t, 3);
267 assert_eq!(s.rollbacks_in_window, 2);
268 }
269
270 #[test]
271 fn watch_fires_once_then_rearms_below_hysteresis() {
272 let cfg = EntropyWatchConfig {
273 enabled: true,
274 threshold: 0.5,
275 hysteresis: 0.1,
276 cooldown_turns: 0,
277 notify_model: false,
278 };
279 let mut t = EntropyTracker::default();
280 let hot = EntropySample {
281 turn: 1,
282 score: 0.7,
283 rho: 0.0,
284 repeat_pressure: 0.0,
285 failure_rate: 0.0,
286 rollbacks_in_window: 0,
287 window_turns: 1,
288 };
289 assert!(t.should_alert(&cfg, &hot));
290 assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
292 assert!(!t.should_alert(
294 &cfg,
295 &EntropySample {
296 turn: 3,
297 score: 0.45,
298 ..hot
299 }
300 ));
301 assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
302 assert!(!t.should_alert(
304 &cfg,
305 &EntropySample {
306 turn: 5,
307 score: 0.3,
308 ..hot
309 }
310 ));
311 assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot }));
312 }
313
314 #[test]
315 fn watch_cooldown_gates_refire_even_after_rearm() {
316 let cfg = EntropyWatchConfig {
317 enabled: true,
318 threshold: 0.5,
319 hysteresis: 0.1,
320 cooldown_turns: 5,
321 notify_model: false,
322 };
323 let mut t = EntropyTracker::default();
324 let hot = EntropySample {
325 turn: 1,
326 score: 0.9,
327 rho: 0.0,
328 repeat_pressure: 0.0,
329 failure_rate: 0.0,
330 rollbacks_in_window: 0,
331 window_turns: 1,
332 };
333 assert!(t.should_alert(&cfg, &hot));
334 assert!(!t.should_alert(
335 &cfg,
336 &EntropySample {
337 turn: 2,
338 score: 0.2,
339 ..hot
340 }
341 )); assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); }
345
346 #[test]
347 fn watch_disabled_never_alerts() {
348 let cfg = EntropyWatchConfig::default();
349 assert!(!cfg.enabled);
350 let mut t = EntropyTracker::default();
351 let hot = EntropySample {
352 turn: 1,
353 score: 1.0,
354 rho: 1.0,
355 repeat_pressure: 1.0,
356 failure_rate: 1.0,
357 rollbacks_in_window: 9,
358 window_turns: 8,
359 };
360 assert!(!t.should_alert(&cfg, &hot));
361 }
362}