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 { 0.65 }
59fn default_hysteresis() -> f64 { 0.1 }
60fn default_cooldown_turns() -> u32 { 4 }
61
62impl Default for EntropyWatchConfig {
63 fn default() -> Self {
64 Self {
65 enabled: false,
66 threshold: default_threshold(),
67 hysteresis: default_hysteresis(),
68 cooldown_turns: default_cooldown_turns(),
69 notify_model: false,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct EntropySample {
77 pub turn: u32,
78 pub score: f64,
80 pub rho: f64,
82 pub repeat_pressure: f64,
85 pub failure_rate: f64,
87 pub rollbacks_in_window: u32,
89 pub window_turns: u32,
91}
92
93#[derive(Debug, Default)]
98pub struct EntropyTracker {
99 turn_stats: VecDeque<(u32, u32)>,
101 rollback_stats: VecDeque<u32>,
103 rollbacks_pending: u32,
106 disarmed: bool,
108 last_alert_turn: Option<u32>,
109}
110
111impl EntropyTracker {
112 pub fn note_rollback(&mut self) {
114 self.rollbacks_pending += 1;
115 }
116
117 pub fn sample(
120 &mut self,
121 turn: u32,
122 rho: f64,
123 repeat_streak: u32,
124 repeat_deny_after: u32,
125 errored_results: u32,
126 total_results: u32,
127 ) -> EntropySample {
128 self.turn_stats.push_back((errored_results, total_results));
129 self.rollback_stats.push_back(std::mem::take(&mut self.rollbacks_pending));
130 while self.turn_stats.len() > ENTROPY_WINDOW_TURNS {
131 self.turn_stats.pop_front();
132 }
133 while self.rollback_stats.len() > ENTROPY_WINDOW_TURNS {
134 self.rollback_stats.pop_front();
135 }
136
137 let (errors, totals) = self
138 .turn_stats
139 .iter()
140 .fold((0u32, 0u32), |(e, t), (te, tt)| (e + te, t + tt));
141 let rollbacks_in_window: u32 = self.rollback_stats.iter().sum();
142
143 let rho = rho.clamp(0.0, 1.0);
144 let repeat_pressure = (f64::from(repeat_streak.saturating_sub(1))
146 / f64::from(repeat_deny_after.max(1)))
147 .clamp(0.0, 1.0);
148 let failure_rate = if totals == 0 { 0.0 } else { f64::from(errors) / f64::from(totals) };
149 let rollback_term = (f64::from(rollbacks_in_window) / ROLLBACK_SATURATION).clamp(0.0, 1.0);
150
151 let score = 0.35 * repeat_pressure + 0.30 * failure_rate + 0.20 * rho + 0.15 * rollback_term;
154
155 EntropySample {
156 turn,
157 score,
158 rho,
159 repeat_pressure,
160 failure_rate,
161 rollbacks_in_window,
162 window_turns: self.turn_stats.len() as u32,
163 }
164 }
165
166 pub fn should_alert(&mut self, config: &EntropyWatchConfig, sample: &EntropySample) -> bool {
169 if !config.enabled {
170 return false;
171 }
172 if sample.score < config.threshold {
173 if sample.score < config.threshold - config.hysteresis {
174 self.disarmed = false;
175 }
176 return false;
177 }
178 if self.disarmed {
179 return false;
180 }
181 let cooled_down = self
182 .last_alert_turn
183 .is_none_or(|t| sample.turn.saturating_sub(t) >= config.cooldown_turns);
184 if !cooled_down {
185 return false;
186 }
187 self.disarmed = true;
188 self.last_alert_turn = Some(sample.turn);
189 true
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn quiet_sample(tracker: &mut EntropyTracker, turn: u32) -> EntropySample {
198 tracker.sample(turn, 0.1, 1, 5, 0, 2)
199 }
200
201 #[test]
202 fn healthy_turn_scores_near_zero() {
203 let mut t = EntropyTracker::default();
204 let s = quiet_sample(&mut t, 1);
205 assert!(s.score < 0.05, "healthy turn score {} should be ~0", s.score);
206 assert_eq!(s.repeat_pressure, 0.0);
207 assert_eq!(s.failure_rate, 0.0);
208 assert_eq!(s.rollbacks_in_window, 0);
209 }
210
211 #[test]
212 fn repetition_and_failures_raise_the_score() {
213 let mut t = EntropyTracker::default();
214 let s = t.sample(3, 0.9, 4, 5, 2, 2);
216 assert!(s.score > 0.6, "disordered turn score {} should be high", s.score);
217 assert!((s.repeat_pressure - 0.6).abs() < 1e-9);
218 assert!((s.failure_rate - 1.0).abs() < 1e-9);
219 }
220
221 #[test]
222 fn failure_rate_windows_out_old_turns() {
223 let mut t = EntropyTracker::default();
224 t.sample(1, 0.1, 1, 5, 3, 3); for turn in 2..=(ENTROPY_WINDOW_TURNS as u32 + 1) {
226 let s = quiet_sample(&mut t, turn);
227 if turn <= ENTROPY_WINDOW_TURNS as u32 {
228 assert!(s.failure_rate > 0.0, "turn {turn} still inside the window");
229 } else {
230 assert_eq!(s.failure_rate, 0.0, "turn {turn} should have evicted the errors");
231 }
232 }
233 }
234
235 #[test]
236 fn rollbacks_accrue_until_a_boundary_completes() {
237 let mut t = EntropyTracker::default();
238 t.note_rollback();
239 t.note_rollback();
240 let s = quiet_sample(&mut t, 2);
241 assert_eq!(s.rollbacks_in_window, 2);
242 let s = quiet_sample(&mut t, 3);
244 assert_eq!(s.rollbacks_in_window, 2);
245 }
246
247 #[test]
248 fn watch_fires_once_then_rearms_below_hysteresis() {
249 let cfg = EntropyWatchConfig { enabled: true, threshold: 0.5, hysteresis: 0.1, cooldown_turns: 0, notify_model: false };
250 let mut t = EntropyTracker::default();
251 let hot = EntropySample { turn: 1, score: 0.7, rho: 0.0, repeat_pressure: 0.0, failure_rate: 0.0, rollbacks_in_window: 0, window_turns: 1 };
252 assert!(t.should_alert(&cfg, &hot));
253 assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
255 assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, score: 0.45, ..hot }));
257 assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
258 assert!(!t.should_alert(&cfg, &EntropySample { turn: 5, score: 0.3, ..hot }));
260 assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot }));
261 }
262
263 #[test]
264 fn watch_cooldown_gates_refire_even_after_rearm() {
265 let cfg = EntropyWatchConfig { enabled: true, threshold: 0.5, hysteresis: 0.1, cooldown_turns: 5, notify_model: false };
266 let mut t = EntropyTracker::default();
267 let hot = EntropySample { turn: 1, score: 0.9, rho: 0.0, repeat_pressure: 0.0, failure_rate: 0.0, rollbacks_in_window: 0, window_turns: 1 };
268 assert!(t.should_alert(&cfg, &hot));
269 assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, score: 0.2, ..hot })); assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); }
273
274 #[test]
275 fn watch_disabled_never_alerts() {
276 let cfg = EntropyWatchConfig::default();
277 assert!(!cfg.enabled);
278 let mut t = EntropyTracker::default();
279 let hot = EntropySample { turn: 1, score: 1.0, rho: 1.0, repeat_pressure: 1.0, failure_rate: 1.0, rollbacks_in_window: 9, window_turns: 8 };
280 assert!(!t.should_alert(&cfg, &hot));
281 }
282}