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 { 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/// One per-turn entropy measurement. All normalized components are in `[0, 1]`.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct EntropySample {
77    pub turn: u32,
78    /// Versioned default fold of the components (see [`ENTROPY_SCORE_VERSION`]).
79    pub score: f64,
80    /// Context pressure after this boundary's eviction pass (`ContextManager::rho`).
81    pub rho: f64,
82    /// Consecutive-identical-turn streak, normalized against the RepeatFuse deny rung
83    /// (0 when the streak is 1 — a first occurrence is not repetition — or the fuse is off).
84    pub repeat_pressure: f64,
85    /// Errored tool results / total tool results over the window.
86    pub failure_rate: f64,
87    /// Raw rollback count inside the window (normalize with `window_turns`).
88    pub rollbacks_in_window: u32,
89    /// Effective window size (completed turns currently held, ≤ [`ENTROPY_WINDOW_TURNS`]).
90    pub window_turns: u32,
91}
92
93/// Sliding-window state feeding [`EntropySample`]. Owned by the state machine; fed at the
94/// completed-turn boundary (`ToolResults`) and by `rollback()`. Deliberately NOT part of
95/// the turn checkpoint: a rollback must not launder the disorder it just evidenced —
96/// the same reasoning as the RepeatFuse streak.
97#[derive(Debug, Default)]
98pub struct EntropyTracker {
99    /// Per completed turn: (errored results, total results).
100    turn_stats: VecDeque<(u32, u32)>,
101    /// Per completed turn: rollbacks observed since the previous completed boundary.
102    rollback_stats: VecDeque<u32>,
103    /// Rollbacks seen since the last completed boundary (turns that roll back return
104    /// early and never reach the sample point — they accrue here until one completes).
105    rollbacks_pending: u32,
106    /// ③ watch state: armed ⇒ the next threshold crossing may alert.
107    disarmed: bool,
108    last_alert_turn: Option<u32>,
109}
110
111impl EntropyTracker {
112    /// Record a rollback (any reason). Called from the state machine's `rollback()`.
113    pub fn note_rollback(&mut self) {
114        self.rollbacks_pending += 1;
115    }
116
117    /// Fold this boundary's outcomes into the window and produce the turn's sample.
118    /// `repeat_streak` is the RepeatFuse consecutive-signature count (0/1 ⇒ no repetition).
119    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        // Streak 1 = first occurrence = zero repetition; pressure saturates at the deny rung.
145        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        // v1 fold: repetition and failures dominate (they are the direct "no forward
152        // progress" evidence), pressure and rollbacks corroborate.
153        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    /// ③ threshold decision for this sample: hysteresis re-arm + cooldown. Mutates the
167    /// watch state; returns `true` when an alert should fire.
168    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        // 4-streak against deny_after=5, every result errored, high pressure.
215        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); // all-error turn
225        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        // Consumed into the window — not double-counted next turn (still windowed though).
243        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        // Still hot: no re-fire until re-armed.
254        assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
255        // Inside the hysteresis band (0.45 ≥ threshold − hysteresis): stays disarmed.
256        assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, score: 0.45, ..hot }));
257        assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
258        // Below the band: re-arms; the next crossing fires again.
259        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 })); // re-arm
270        assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); // armed but cooling
271        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); // 6−1 ≥ 5
272    }
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}