Skip to main content

deepstrike_core/scheduler/
entropy.rs

1//! Session-entropy sampling — the kernel-side measurement behind a host "heartbeat
2//! entropy watch" source.
3//!
4//! "Entropy" here is session *disorder*: the degree to which a run is churning without
5//! converging — repeating itself, failing tool calls, rolling turns back, and running out
6//! of context headroom. The kernel already detects each symptom in isolation (2c STOP /
7//! RepeatFuse / rollback / eviction); this module folds them into one per-turn sample the
8//! host can subscribe to, so an external supervisor (heartbeat) can decide *its* policy —
9//! e.g. inject a corrective note — without re-deriving kernel state from the audit log.
10//!
11//! Two honesty rules govern the shape:
12//! - The component vector is the contract; `score` is only a *versioned* default fold
13//!   ([`ENTROPY_SCORE_VERSION`]). Hosts that care should threshold on components.
14//! - Measurement is unconditional (one sample per completed turn boundary, like
15//!   `CheckpointTaken`); only the alert — a kernel-side threshold decision — is opt-in
16//!   via [`EntropyWatchConfig`].
17
18use serde::{Deserialize, Serialize};
19use std::collections::VecDeque;
20
21/// Version of the default `score` fold. Bump when the formula or weights change so hosts
22/// thresholding on `score` can detect the semantics shift.
23pub const ENTROPY_SCORE_VERSION: u32 = 1;
24
25/// Sliding window (in completed turns) for the failure/rollback components.
26pub const ENTROPY_WINDOW_TURNS: usize = 8;
27
28/// Saturation point for the rollback component: this many rollbacks inside the window
29/// reads as fully disordered (1.0) on that axis.
30const ROLLBACK_SATURATION: f64 = 3.0;
31
32/// Opt-in threshold watch over the per-turn entropy score (③). When the score crosses
33/// `threshold` the kernel emits an `EntropyAlert` observation — at most once per crossing
34/// (hysteresis re-arm) and never more often than `cooldown_turns`. With `notify_model`
35/// the alert is *also* routed through the kernel's own signal dispatch as a
36/// `Heartbeat/Alert` [`RuntimeSignal`](crate::types::signal::RuntimeSignal), so the model
37/// sees a durable `[SIGNAL]` directive at the next boundary. Default OFF: the primary
38/// consumer is the host supervisor, which can inject a task-aware note itself — an
39/// unconditional self-nudge risks a feedback loop (the note churns context → more entropy).
40#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
41pub struct EntropyWatchConfig {
42    #[serde(default)]
43    pub enabled: bool,
44    /// Alert when `score >= threshold`.
45    #[serde(default = "default_threshold")]
46    pub threshold: f64,
47    /// Re-arm only after the score falls below `threshold - hysteresis` (anti-flap).
48    #[serde(default = "default_hysteresis")]
49    pub hysteresis: f64,
50    /// Minimum completed turns between two alerts.
51    #[serde(default = "default_cooldown_turns")]
52    pub cooldown_turns: u32,
53    /// Also self-signal the model (Heartbeat/Alert, High urgency) when the alert fires.
54    #[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/// One per-turn entropy measurement. All normalized components are in `[0, 1]`.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct EntropySample {
83    pub turn: u32,
84    /// Versioned default fold of the components (see [`ENTROPY_SCORE_VERSION`]).
85    pub score: f64,
86    /// Context pressure after this boundary's eviction pass (`ContextManager::rho`).
87    pub rho: f64,
88    /// Consecutive-identical-turn streak, normalized against the RepeatFuse deny rung
89    /// (0 when the streak is 1 — a first occurrence is not repetition — or the fuse is off).
90    pub repeat_pressure: f64,
91    /// Errored tool results / total tool results over the window.
92    pub failure_rate: f64,
93    /// Raw rollback count inside the window (normalize with `window_turns`).
94    pub rollbacks_in_window: u32,
95    /// Effective window size (completed turns currently held, ≤ [`ENTROPY_WINDOW_TURNS`]).
96    pub window_turns: u32,
97}
98
99/// Sliding-window state feeding [`EntropySample`]. Owned by the state machine; fed at the
100/// completed-turn boundary (`ToolResults`) and by `rollback()`. Deliberately NOT part of
101/// the turn checkpoint: a rollback must not launder the disorder it just evidenced —
102/// the same reasoning as the RepeatFuse streak.
103#[derive(Debug, Default)]
104pub struct EntropyTracker {
105    /// Per completed turn: (errored results, total results).
106    turn_stats: VecDeque<(u32, u32)>,
107    /// Per completed turn: rollbacks observed since the previous completed boundary.
108    rollback_stats: VecDeque<u32>,
109    /// Rollbacks seen since the last completed boundary (turns that roll back return
110    /// early and never reach the sample point — they accrue here until one completes).
111    rollbacks_pending: u32,
112    /// ③ watch state: armed ⇒ the next threshold crossing may alert.
113    disarmed: bool,
114    last_alert_turn: Option<u32>,
115}
116
117impl EntropyTracker {
118    /// Record a rollback (any reason). Called from the state machine's `rollback()`.
119    pub fn note_rollback(&mut self) {
120        self.rollbacks_pending += 1;
121    }
122
123    /// Fold this boundary's outcomes into the window and produce the turn's sample.
124    /// `repeat_streak` is the RepeatFuse consecutive-signature count (0/1 ⇒ no repetition).
125    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        // Streak 1 = first occurrence = zero repetition; pressure saturates at the deny rung.
152        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        // v1 fold: repetition and failures dominate (they are the direct "no forward
163        // progress" evidence), pressure and rollbacks corroborate.
164        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    /// ③ threshold decision for this sample: hysteresis re-arm + cooldown. Mutates the
179    /// watch state; returns `true` when an alert should fire.
180    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        // 4-streak against deny_after=5, every result errored, high pressure.
231        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); // all-error turn
245        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        // Consumed into the window — not double-counted next turn (still windowed though).
266        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        // Still hot: no re-fire until re-armed.
291        assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
292        // Inside the hysteresis band (0.45 ≥ threshold − hysteresis): stays disarmed.
293        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        // Below the band: re-arms; the next crossing fires again.
303        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        )); // re-arm
342        assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); // armed but cooling
343        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); // 6−1 ≥ 5
344    }
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}