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/// F5 special note (registered in `crate::projection_pairs`, 0.2.66):异名 projection
29/// pair with wire `EntropyWatchPolicy` (the ABI authority); conversion lives in the
30/// syscall/config projection.
31/// Opt-in threshold watch over the per-turn entropy score (③). When the score crosses
32/// `threshold` the kernel emits an `EntropyAlert` observation — at most once per crossing
33/// (hysteresis re-arm) and never more often than `cooldown_turns`. With `notify_model`
34/// the alert is *also* routed through the kernel's own signal dispatch as a
35/// `Heartbeat/Alert` [`RuntimeSignal`](crate::types::signal::RuntimeSignal), so the model
36/// sees a durable `[SIGNAL]` directive at the next boundary. Default OFF: the primary
37/// consumer is the host supervisor, which can inject a task-aware note itself — an
38/// unconditional self-nudge risks a feedback loop (the note churns context → more entropy).
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct EntropyWatchConfig {
41    #[serde(default)]
42    pub enabled: bool,
43    /// Alert when `score >= threshold`.
44    #[serde(default = "default_threshold")]
45    pub threshold: f64,
46    /// Re-arm only after the score falls below `threshold - hysteresis` (anti-flap).
47    #[serde(default = "default_hysteresis")]
48    pub hysteresis: f64,
49    /// Minimum completed turns between two alerts.
50    #[serde(default = "default_cooldown_turns")]
51    pub cooldown_turns: u32,
52    /// Also self-signal the model (Heartbeat/Alert, High urgency) when the alert fires.
53    #[serde(default)]
54    pub notify_model: bool,
55}
56
57fn default_threshold() -> f64 {
58    0.65
59}
60fn default_hysteresis() -> f64 {
61    0.1
62}
63fn default_cooldown_turns() -> u32 {
64    4
65}
66
67impl Default for EntropyWatchConfig {
68    fn default() -> Self {
69        Self {
70            enabled: false,
71            threshold: default_threshold(),
72            hysteresis: default_hysteresis(),
73            cooldown_turns: default_cooldown_turns(),
74            notify_model: false,
75        }
76    }
77}
78
79/// One per-turn entropy measurement. All normalized components are in `[0, 1]`.
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct EntropySample {
82    pub turn: u32,
83    /// Canonical default fold of the components.
84    pub score: f64,
85    /// Context pressure after this boundary's eviction pass (`ContextManager::rho`).
86    pub rho: f64,
87    /// Consecutive-identical-turn streak, normalized against the RepeatFuse deny rung
88    /// (0 when the streak is 1 — a first occurrence is not repetition — or the fuse is off).
89    pub repeat_pressure: f64,
90    /// Errored tool results / total tool results over the window.
91    pub failure_rate: f64,
92    /// Raw rollback count inside the window (normalize with `window_turns`).
93    pub rollbacks_in_window: u32,
94    /// Effective window size (completed turns currently held, ≤ [`ENTROPY_WINDOW_TURNS`]).
95    pub window_turns: u32,
96}
97
98/// Sliding-window state feeding [`EntropySample`]. Owned by the state machine; fed at the
99/// completed-turn boundary (`ToolResults`) and by `rollback()`. Deliberately NOT part of
100/// the turn checkpoint: a rollback must not launder the disorder it just evidenced —
101/// the same reasoning as the RepeatFuse streak.
102#[derive(Debug, Default)]
103pub struct EntropyTracker {
104    /// Per completed turn: (errored results, total results).
105    turn_stats: VecDeque<(u32, u32)>,
106    /// Per completed turn: rollbacks observed since the previous completed boundary.
107    rollback_stats: VecDeque<u32>,
108    /// Rollbacks seen since the last completed boundary (turns that roll back return
109    /// early and never reach the sample point — they accrue here until one completes).
110    rollbacks_pending: u32,
111    /// ③ watch state: armed ⇒ the next threshold crossing may alert.
112    disarmed: bool,
113    last_alert_turn: Option<u32>,
114}
115
116/// Private-layout-free runtime projection used by the canonical checkpoint adapter.
117///
118/// The checkpoint wire type deliberately lives under `runtime::kernel::wire`; this type only
119/// gives that adapter an invertible view of the tracker without making its two `VecDeque`s part of
120/// the public ABI.
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub(crate) struct EntropyTrackerRuntimeState {
123    pub window: Vec<EntropyTurnRuntimeState>,
124    pub rollbacks_pending: u32,
125    pub disarmed: bool,
126    pub last_alert_turn: Option<u32>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub(crate) struct EntropyTurnRuntimeState {
131    pub errored_results: u32,
132    pub total_results: u32,
133    pub rollbacks: u32,
134}
135
136impl EntropyTracker {
137    pub(crate) fn checkpoint_state(&self) -> EntropyTrackerRuntimeState {
138        debug_assert_eq!(
139            self.turn_stats.len(),
140            self.rollback_stats.len(),
141            "entropy outcome and rollback windows advance together"
142        );
143        EntropyTrackerRuntimeState {
144            window: self
145                .turn_stats
146                .iter()
147                .zip(self.rollback_stats.iter())
148                .map(
149                    |(&(errored_results, total_results), &rollbacks)| EntropyTurnRuntimeState {
150                        errored_results,
151                        total_results,
152                        rollbacks,
153                    },
154                )
155                .collect(),
156            rollbacks_pending: self.rollbacks_pending,
157            disarmed: self.disarmed,
158            last_alert_turn: self.last_alert_turn,
159        }
160    }
161
162    pub(crate) fn restore_state(
163        &mut self,
164        state: EntropyTrackerRuntimeState,
165        current_turn: u32,
166    ) -> Result<(), String> {
167        if state.window.len() > ENTROPY_WINDOW_TURNS {
168            return Err(format!(
169                "entropy window carries {} turns; maximum is {ENTROPY_WINDOW_TURNS}",
170                state.window.len()
171            ));
172        }
173        if let Some(turn) = state.last_alert_turn
174            && turn > current_turn
175        {
176            return Err(format!(
177                "entropy alert turn {turn} is later than scheduler turn {current_turn}"
178            ));
179        }
180        if let Some(invalid) = state
181            .window
182            .iter()
183            .find(|entry| entry.errored_results > entry.total_results)
184        {
185            return Err(format!(
186                "entropy window carries {} errors for only {} results",
187                invalid.errored_results, invalid.total_results
188            ));
189        }
190
191        self.turn_stats = state
192            .window
193            .iter()
194            .map(|entry| (entry.errored_results, entry.total_results))
195            .collect();
196        self.rollback_stats = state.window.iter().map(|entry| entry.rollbacks).collect();
197        self.rollbacks_pending = state.rollbacks_pending;
198        self.disarmed = state.disarmed;
199        self.last_alert_turn = state.last_alert_turn;
200        Ok(())
201    }
202
203    /// Record a rollback (any reason). Called from the state machine's `rollback()`.
204    pub fn note_rollback(&mut self) {
205        self.rollbacks_pending += 1;
206    }
207
208    /// Fold this boundary's outcomes into the window and produce the turn's sample.
209    /// `repeat_streak` is the RepeatFuse consecutive-signature count (0/1 ⇒ no repetition).
210    pub fn sample(
211        &mut self,
212        turn: u32,
213        rho: f64,
214        repeat_streak: u32,
215        repeat_deny_after: u32,
216        errored_results: u32,
217        total_results: u32,
218    ) -> EntropySample {
219        self.turn_stats.push_back((errored_results, total_results));
220        self.rollback_stats
221            .push_back(std::mem::take(&mut self.rollbacks_pending));
222        while self.turn_stats.len() > ENTROPY_WINDOW_TURNS {
223            self.turn_stats.pop_front();
224        }
225        while self.rollback_stats.len() > ENTROPY_WINDOW_TURNS {
226            self.rollback_stats.pop_front();
227        }
228
229        let (errors, totals) = self
230            .turn_stats
231            .iter()
232            .fold((0u32, 0u32), |(e, t), (te, tt)| (e + te, t + tt));
233        let rollbacks_in_window: u32 = self.rollback_stats.iter().sum();
234
235        let rho = rho.clamp(0.0, 1.0);
236        // Streak 1 = first occurrence = zero repetition; pressure saturates at the deny rung.
237        let repeat_pressure = (f64::from(repeat_streak.saturating_sub(1))
238            / f64::from(repeat_deny_after.max(1)))
239        .clamp(0.0, 1.0);
240        let failure_rate = if totals == 0 {
241            0.0
242        } else {
243            f64::from(errors) / f64::from(totals)
244        };
245        let rollback_term = (f64::from(rollbacks_in_window) / ROLLBACK_SATURATION).clamp(0.0, 1.0);
246
247        // canonical fold: repetition and failures dominate (they are the direct "no forward
248        // progress" evidence), pressure and rollbacks corroborate.
249        let score =
250            0.35 * repeat_pressure + 0.30 * failure_rate + 0.20 * rho + 0.15 * rollback_term;
251
252        EntropySample {
253            turn,
254            score,
255            rho,
256            repeat_pressure,
257            failure_rate,
258            rollbacks_in_window,
259            window_turns: self.turn_stats.len() as u32,
260        }
261    }
262
263    /// ③ threshold decision for this sample: hysteresis re-arm + cooldown. Mutates the
264    /// watch state; returns `true` when an alert should fire.
265    pub fn should_alert(&mut self, config: &EntropyWatchConfig, sample: &EntropySample) -> bool {
266        if !config.enabled {
267            return false;
268        }
269        if sample.score < config.threshold {
270            if sample.score < config.threshold - config.hysteresis {
271                self.disarmed = false;
272            }
273            return false;
274        }
275        if self.disarmed {
276            return false;
277        }
278        let cooled_down = self
279            .last_alert_turn
280            .is_none_or(|t| sample.turn.saturating_sub(t) >= config.cooldown_turns);
281        if !cooled_down {
282            return false;
283        }
284        self.disarmed = true;
285        self.last_alert_turn = Some(sample.turn);
286        true
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn quiet_sample(tracker: &mut EntropyTracker, turn: u32) -> EntropySample {
295        tracker.sample(turn, 0.1, 1, 5, 0, 2)
296    }
297
298    #[test]
299    fn healthy_turn_scores_near_zero() {
300        let mut t = EntropyTracker::default();
301        let s = quiet_sample(&mut t, 1);
302        assert!(
303            s.score < 0.05,
304            "healthy turn score {} should be ~0",
305            s.score
306        );
307        assert_eq!(s.repeat_pressure, 0.0);
308        assert_eq!(s.failure_rate, 0.0);
309        assert_eq!(s.rollbacks_in_window, 0);
310    }
311
312    #[test]
313    fn repetition_and_failures_raise_the_score() {
314        let mut t = EntropyTracker::default();
315        // 4-streak against deny_after=5, every result errored, high pressure.
316        let s = t.sample(3, 0.9, 4, 5, 2, 2);
317        assert!(
318            s.score > 0.6,
319            "disordered turn score {} should be high",
320            s.score
321        );
322        assert!((s.repeat_pressure - 0.6).abs() < 1e-9);
323        assert!((s.failure_rate - 1.0).abs() < 1e-9);
324    }
325
326    #[test]
327    fn failure_rate_windows_out_old_turns() {
328        let mut t = EntropyTracker::default();
329        t.sample(1, 0.1, 1, 5, 3, 3); // all-error turn
330        for turn in 2..=(ENTROPY_WINDOW_TURNS as u32 + 1) {
331            let s = quiet_sample(&mut t, turn);
332            if turn <= ENTROPY_WINDOW_TURNS as u32 {
333                assert!(s.failure_rate > 0.0, "turn {turn} still inside the window");
334            } else {
335                assert_eq!(
336                    s.failure_rate, 0.0,
337                    "turn {turn} should have evicted the errors"
338                );
339            }
340        }
341    }
342
343    #[test]
344    fn rollbacks_accrue_until_a_boundary_completes() {
345        let mut t = EntropyTracker::default();
346        t.note_rollback();
347        t.note_rollback();
348        let s = quiet_sample(&mut t, 2);
349        assert_eq!(s.rollbacks_in_window, 2);
350        // Consumed into the window — not double-counted next turn (still windowed though).
351        let s = quiet_sample(&mut t, 3);
352        assert_eq!(s.rollbacks_in_window, 2);
353    }
354
355    #[test]
356    fn watch_fires_once_then_rearms_below_hysteresis() {
357        let cfg = EntropyWatchConfig {
358            enabled: true,
359            threshold: 0.5,
360            hysteresis: 0.1,
361            cooldown_turns: 0,
362            notify_model: false,
363        };
364        let mut t = EntropyTracker::default();
365        let hot = EntropySample {
366            turn: 1,
367            score: 0.7,
368            rho: 0.0,
369            repeat_pressure: 0.0,
370            failure_rate: 0.0,
371            rollbacks_in_window: 0,
372            window_turns: 1,
373        };
374        assert!(t.should_alert(&cfg, &hot));
375        // Still hot: no re-fire until re-armed.
376        assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
377        // Inside the hysteresis band (0.45 ≥ threshold − hysteresis): stays disarmed.
378        assert!(!t.should_alert(
379            &cfg,
380            &EntropySample {
381                turn: 3,
382                score: 0.45,
383                ..hot
384            }
385        ));
386        assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
387        // Below the band: re-arms; the next crossing fires again.
388        assert!(!t.should_alert(
389            &cfg,
390            &EntropySample {
391                turn: 5,
392                score: 0.3,
393                ..hot
394            }
395        ));
396        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot }));
397    }
398
399    #[test]
400    fn watch_cooldown_gates_refire_even_after_rearm() {
401        let cfg = EntropyWatchConfig {
402            enabled: true,
403            threshold: 0.5,
404            hysteresis: 0.1,
405            cooldown_turns: 5,
406            notify_model: false,
407        };
408        let mut t = EntropyTracker::default();
409        let hot = EntropySample {
410            turn: 1,
411            score: 0.9,
412            rho: 0.0,
413            repeat_pressure: 0.0,
414            failure_rate: 0.0,
415            rollbacks_in_window: 0,
416            window_turns: 1,
417        };
418        assert!(t.should_alert(&cfg, &hot));
419        assert!(!t.should_alert(
420            &cfg,
421            &EntropySample {
422                turn: 2,
423                score: 0.2,
424                ..hot
425            }
426        )); // re-arm
427        assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); // armed but cooling
428        assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); // 6−1 ≥ 5
429    }
430
431    #[test]
432    fn watch_disabled_never_alerts() {
433        let cfg = EntropyWatchConfig::default();
434        assert!(!cfg.enabled);
435        let mut t = EntropyTracker::default();
436        let hot = EntropySample {
437            turn: 1,
438            score: 1.0,
439            rho: 1.0,
440            repeat_pressure: 1.0,
441            failure_rate: 1.0,
442            rollbacks_in_window: 9,
443            window_turns: 8,
444        };
445        assert!(!t.should_alert(&cfg, &hot));
446    }
447}