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
117/// Private-layout-free runtime projection used by the canonical checkpoint adapter.
118///
119/// The checkpoint wire type deliberately lives under `runtime::kernel::wire`; this type only
120/// gives that adapter an invertible view of the tracker without making its two `VecDeque`s part of
121/// the public ABI.
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
123pub(crate) struct EntropyTrackerRuntimeState {
124    pub window: Vec<EntropyTurnRuntimeState>,
125    pub rollbacks_pending: u32,
126    pub disarmed: bool,
127    pub last_alert_turn: Option<u32>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub(crate) struct EntropyTurnRuntimeState {
132    pub errored_results: u32,
133    pub total_results: u32,
134    pub rollbacks: u32,
135}
136
137impl EntropyTracker {
138    pub(crate) fn checkpoint_state(&self) -> EntropyTrackerRuntimeState {
139        debug_assert_eq!(
140            self.turn_stats.len(),
141            self.rollback_stats.len(),
142            "entropy outcome and rollback windows advance together"
143        );
144        EntropyTrackerRuntimeState {
145            window: self
146                .turn_stats
147                .iter()
148                .zip(self.rollback_stats.iter())
149                .map(
150                    |(&(errored_results, total_results), &rollbacks)| EntropyTurnRuntimeState {
151                        errored_results,
152                        total_results,
153                        rollbacks,
154                    },
155                )
156                .collect(),
157            rollbacks_pending: self.rollbacks_pending,
158            disarmed: self.disarmed,
159            last_alert_turn: self.last_alert_turn,
160        }
161    }
162
163    pub(crate) fn restore_state(
164        &mut self,
165        state: EntropyTrackerRuntimeState,
166        current_turn: u32,
167    ) -> Result<(), String> {
168        if state.window.len() > ENTROPY_WINDOW_TURNS {
169            return Err(format!(
170                "entropy window carries {} turns; maximum is {ENTROPY_WINDOW_TURNS}",
171                state.window.len()
172            ));
173        }
174        if let Some(turn) = state.last_alert_turn
175            && turn > current_turn
176        {
177            return Err(format!(
178                "entropy alert turn {turn} is later than scheduler turn {current_turn}"
179            ));
180        }
181        if let Some(invalid) = state
182            .window
183            .iter()
184            .find(|entry| entry.errored_results > entry.total_results)
185        {
186            return Err(format!(
187                "entropy window carries {} errors for only {} results",
188                invalid.errored_results, invalid.total_results
189            ));
190        }
191
192        self.turn_stats = state
193            .window
194            .iter()
195            .map(|entry| (entry.errored_results, entry.total_results))
196            .collect();
197        self.rollback_stats = state.window.iter().map(|entry| entry.rollbacks).collect();
198        self.rollbacks_pending = state.rollbacks_pending;
199        self.disarmed = state.disarmed;
200        self.last_alert_turn = state.last_alert_turn;
201        Ok(())
202    }
203
204    /// Record a rollback (any reason). Called from the state machine's `rollback()`.
205    pub fn note_rollback(&mut self) {
206        self.rollbacks_pending += 1;
207    }
208
209    /// Fold this boundary's outcomes into the window and produce the turn's sample.
210    /// `repeat_streak` is the RepeatFuse consecutive-signature count (0/1 ⇒ no repetition).
211    pub fn sample(
212        &mut self,
213        turn: u32,
214        rho: f64,
215        repeat_streak: u32,
216        repeat_deny_after: u32,
217        errored_results: u32,
218        total_results: u32,
219    ) -> EntropySample {
220        self.turn_stats.push_back((errored_results, total_results));
221        self.rollback_stats
222            .push_back(std::mem::take(&mut self.rollbacks_pending));
223        while self.turn_stats.len() > ENTROPY_WINDOW_TURNS {
224            self.turn_stats.pop_front();
225        }
226        while self.rollback_stats.len() > ENTROPY_WINDOW_TURNS {
227            self.rollback_stats.pop_front();
228        }
229
230        let (errors, totals) = self
231            .turn_stats
232            .iter()
233            .fold((0u32, 0u32), |(e, t), (te, tt)| (e + te, t + tt));
234        let rollbacks_in_window: u32 = self.rollback_stats.iter().sum();
235
236        let rho = rho.clamp(0.0, 1.0);
237        // Streak 1 = first occurrence = zero repetition; pressure saturates at the deny rung.
238        let repeat_pressure = (f64::from(repeat_streak.saturating_sub(1))
239            / f64::from(repeat_deny_after.max(1)))
240        .clamp(0.0, 1.0);
241        let failure_rate = if totals == 0 {
242            0.0
243        } else {
244            f64::from(errors) / f64::from(totals)
245        };
246        let rollback_term = (f64::from(rollbacks_in_window) / ROLLBACK_SATURATION).clamp(0.0, 1.0);
247
248        // v1 fold: repetition and failures dominate (they are the direct "no forward
249        // progress" evidence), pressure and rollbacks corroborate.
250        let score =
251            0.35 * repeat_pressure + 0.30 * failure_rate + 0.20 * rho + 0.15 * rollback_term;
252
253        EntropySample {
254            turn,
255            score,
256            rho,
257            repeat_pressure,
258            failure_rate,
259            rollbacks_in_window,
260            window_turns: self.turn_stats.len() as u32,
261        }
262    }
263
264    /// ③ threshold decision for this sample: hysteresis re-arm + cooldown. Mutates the
265    /// watch state; returns `true` when an alert should fire.
266    pub fn should_alert(&mut self, config: &EntropyWatchConfig, sample: &EntropySample) -> bool {
267        if !config.enabled {
268            return false;
269        }
270        if sample.score < config.threshold {
271            if sample.score < config.threshold - config.hysteresis {
272                self.disarmed = false;
273            }
274            return false;
275        }
276        if self.disarmed {
277            return false;
278        }
279        let cooled_down = self
280            .last_alert_turn
281            .is_none_or(|t| sample.turn.saturating_sub(t) >= config.cooldown_turns);
282        if !cooled_down {
283            return false;
284        }
285        self.disarmed = true;
286        self.last_alert_turn = Some(sample.turn);
287        true
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn quiet_sample(tracker: &mut EntropyTracker, turn: u32) -> EntropySample {
296        tracker.sample(turn, 0.1, 1, 5, 0, 2)
297    }
298
299    #[test]
300    fn healthy_turn_scores_near_zero() {
301        let mut t = EntropyTracker::default();
302        let s = quiet_sample(&mut t, 1);
303        assert!(
304            s.score < 0.05,
305            "healthy turn score {} should be ~0",
306            s.score
307        );
308        assert_eq!(s.repeat_pressure, 0.0);
309        assert_eq!(s.failure_rate, 0.0);
310        assert_eq!(s.rollbacks_in_window, 0);
311    }
312
313    #[test]
314    fn repetition_and_failures_raise_the_score() {
315        let mut t = EntropyTracker::default();
316        // 4-streak against deny_after=5, every result errored, high pressure.
317        let s = t.sample(3, 0.9, 4, 5, 2, 2);
318        assert!(
319            s.score > 0.6,
320            "disordered turn score {} should be high",
321            s.score
322        );
323        assert!((s.repeat_pressure - 0.6).abs() < 1e-9);
324        assert!((s.failure_rate - 1.0).abs() < 1e-9);
325    }
326
327    #[test]
328    fn failure_rate_windows_out_old_turns() {
329        let mut t = EntropyTracker::default();
330        t.sample(1, 0.1, 1, 5, 3, 3); // all-error turn
331        for turn in 2..=(ENTROPY_WINDOW_TURNS as u32 + 1) {
332            let s = quiet_sample(&mut t, turn);
333            if turn <= ENTROPY_WINDOW_TURNS as u32 {
334                assert!(s.failure_rate > 0.0, "turn {turn} still inside the window");
335            } else {
336                assert_eq!(
337                    s.failure_rate, 0.0,
338                    "turn {turn} should have evicted the errors"
339                );
340            }
341        }
342    }
343
344    #[test]
345    fn rollbacks_accrue_until_a_boundary_completes() {
346        let mut t = EntropyTracker::default();
347        t.note_rollback();
348        t.note_rollback();
349        let s = quiet_sample(&mut t, 2);
350        assert_eq!(s.rollbacks_in_window, 2);
351        // Consumed into the window — not double-counted next turn (still windowed though).
352        let s = quiet_sample(&mut t, 3);
353        assert_eq!(s.rollbacks_in_window, 2);
354    }
355
356    #[test]
357    fn watch_fires_once_then_rearms_below_hysteresis() {
358        let cfg = EntropyWatchConfig {
359            enabled: true,
360            threshold: 0.5,
361            hysteresis: 0.1,
362            cooldown_turns: 0,
363            notify_model: false,
364        };
365        let mut t = EntropyTracker::default();
366        let hot = EntropySample {
367            turn: 1,
368            score: 0.7,
369            rho: 0.0,
370            repeat_pressure: 0.0,
371            failure_rate: 0.0,
372            rollbacks_in_window: 0,
373            window_turns: 1,
374        };
375        assert!(t.should_alert(&cfg, &hot));
376        // Still hot: no re-fire until re-armed.
377        assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
378        // Inside the hysteresis band (0.45 ≥ threshold − hysteresis): stays disarmed.
379        assert!(!t.should_alert(
380            &cfg,
381            &EntropySample {
382                turn: 3,
383                score: 0.45,
384                ..hot
385            }
386        ));
387        assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
388        // Below the band: re-arms; the next crossing fires again.
389        assert!(!t.should_alert(
390            &cfg,
391            &EntropySample {
392                turn: 5,
393                score: 0.3,
394                ..hot
395            }
396        ));
397        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot }));
398    }
399
400    #[test]
401    fn watch_cooldown_gates_refire_even_after_rearm() {
402        let cfg = EntropyWatchConfig {
403            enabled: true,
404            threshold: 0.5,
405            hysteresis: 0.1,
406            cooldown_turns: 5,
407            notify_model: false,
408        };
409        let mut t = EntropyTracker::default();
410        let hot = EntropySample {
411            turn: 1,
412            score: 0.9,
413            rho: 0.0,
414            repeat_pressure: 0.0,
415            failure_rate: 0.0,
416            rollbacks_in_window: 0,
417            window_turns: 1,
418        };
419        assert!(t.should_alert(&cfg, &hot));
420        assert!(!t.should_alert(
421            &cfg,
422            &EntropySample {
423                turn: 2,
424                score: 0.2,
425                ..hot
426            }
427        )); // re-arm
428        assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); // armed but cooling
429        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); // 6−1 ≥ 5
430    }
431
432    #[test]
433    fn watch_disabled_never_alerts() {
434        let cfg = EntropyWatchConfig::default();
435        assert!(!cfg.enabled);
436        let mut t = EntropyTracker::default();
437        let hot = EntropySample {
438            turn: 1,
439            score: 1.0,
440            rho: 1.0,
441            repeat_pressure: 1.0,
442            failure_rate: 1.0,
443            rollbacks_in_window: 9,
444            window_turns: 8,
445        };
446        assert!(!t.should_alert(&cfg, &hot));
447    }
448}