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 the canonical default fold. Hosts that care
13//!   about individual causes 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/// Sliding window (in completed turns) for the failure/rollback components.
22pub const ENTROPY_WINDOW_TURNS: usize = 8;
23
24/// Saturation point for the rollback component: this many rollbacks inside the window
25/// reads as fully disordered (1.0) on that axis.
26const ROLLBACK_SATURATION: f64 = 3.0;
27
28/// Opt-in threshold watch over the per-turn entropy score (③). When the score crosses
29/// `threshold` the kernel emits an `EntropyAlert` observation — at most once per crossing
30/// (hysteresis re-arm) and never more often than `cooldown_turns`. With `notify_model`
31/// the alert is *also* routed through the kernel's own signal dispatch as a
32/// `Heartbeat/Alert` [`RuntimeSignal`](crate::types::signal::RuntimeSignal), so the model
33/// sees a durable `[SIGNAL]` directive at the next boundary. Default OFF: the primary
34/// consumer is the host supervisor, which can inject a task-aware note itself — an
35/// unconditional self-nudge risks a feedback loop (the note churns context → more entropy).
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37pub struct EntropyWatchConfig {
38    #[serde(default)]
39    pub enabled: bool,
40    /// Alert when `score >= threshold`.
41    #[serde(default = "default_threshold")]
42    pub threshold: f64,
43    /// Re-arm only after the score falls below `threshold - hysteresis` (anti-flap).
44    #[serde(default = "default_hysteresis")]
45    pub hysteresis: f64,
46    /// Minimum completed turns between two alerts.
47    #[serde(default = "default_cooldown_turns")]
48    pub cooldown_turns: u32,
49    /// Also self-signal the model (Heartbeat/Alert, High urgency) when the alert fires.
50    #[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/// One per-turn entropy measurement. All normalized components are in `[0, 1]`.
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct EntropySample {
79    pub turn: u32,
80    /// Canonical default fold of the components.
81    pub score: f64,
82    /// Context pressure after this boundary's eviction pass (`ContextManager::rho`).
83    pub rho: f64,
84    /// Consecutive-identical-turn streak, normalized against the RepeatFuse deny rung
85    /// (0 when the streak is 1 — a first occurrence is not repetition — or the fuse is off).
86    pub repeat_pressure: f64,
87    /// Errored tool results / total tool results over the window.
88    pub failure_rate: f64,
89    /// Raw rollback count inside the window (normalize with `window_turns`).
90    pub rollbacks_in_window: u32,
91    /// Effective window size (completed turns currently held, ≤ [`ENTROPY_WINDOW_TURNS`]).
92    pub window_turns: u32,
93}
94
95/// Sliding-window state feeding [`EntropySample`]. Owned by the state machine; fed at the
96/// completed-turn boundary (`ToolResults`) and by `rollback()`. Deliberately NOT part of
97/// the turn checkpoint: a rollback must not launder the disorder it just evidenced —
98/// the same reasoning as the RepeatFuse streak.
99#[derive(Debug, Default)]
100pub struct EntropyTracker {
101    /// Per completed turn: (errored results, total results).
102    turn_stats: VecDeque<(u32, u32)>,
103    /// Per completed turn: rollbacks observed since the previous completed boundary.
104    rollback_stats: VecDeque<u32>,
105    /// Rollbacks seen since the last completed boundary (turns that roll back return
106    /// early and never reach the sample point — they accrue here until one completes).
107    rollbacks_pending: u32,
108    /// ③ watch state: armed ⇒ the next threshold crossing may alert.
109    disarmed: bool,
110    last_alert_turn: Option<u32>,
111}
112
113/// Private-layout-free runtime projection used by the canonical checkpoint adapter.
114///
115/// The checkpoint wire type deliberately lives under `runtime::kernel::wire`; this type only
116/// gives that adapter an invertible view of the tracker without making its two `VecDeque`s part of
117/// the public ABI.
118#[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    /// Record a rollback (any reason). Called from the state machine's `rollback()`.
201    pub fn note_rollback(&mut self) {
202        self.rollbacks_pending += 1;
203    }
204
205    /// Fold this boundary's outcomes into the window and produce the turn's sample.
206    /// `repeat_streak` is the RepeatFuse consecutive-signature count (0/1 ⇒ no repetition).
207    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        // Streak 1 = first occurrence = zero repetition; pressure saturates at the deny rung.
234        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        // canonical fold: repetition and failures dominate (they are the direct "no forward
245        // progress" evidence), pressure and rollbacks corroborate.
246        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    /// ③ threshold decision for this sample: hysteresis re-arm + cooldown. Mutates the
261    /// watch state; returns `true` when an alert should fire.
262    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        // 4-streak against deny_after=5, every result errored, high pressure.
313        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); // all-error turn
327        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        // Consumed into the window — not double-counted next turn (still windowed though).
348        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        // Still hot: no re-fire until re-armed.
373        assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
374        // Inside the hysteresis band (0.45 ≥ threshold − hysteresis): stays disarmed.
375        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        // Below the band: re-arms; the next crossing fires again.
385        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        )); // re-arm
424        assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); // armed but cooling
425        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); // 6−1 ≥ 5
426    }
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}