Skip to main content

zeph_core/agent/
trajectory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Trajectory risk sentinel: accumulates risk signals across turns and exposes
5//! an advisory `RiskLevel` consumed by `PolicyGateExecutor`.
6//!
7//! # Architecture
8//!
9//! `TrajectorySentinel` is stored on `SecurityState` (per-agent, never global).
10//! `advance_turn()` MUST be called once per turn, **before** `PolicyGateExecutor::check_policy`
11//! runs (Invariant 2 in spec 050). This guarantees that decay is applied before the gate
12//! evaluates the current-turn score.
13//!
14//! # LLM isolation
15//!
16//! `RiskAlert`, `RiskLevel`, and sentinel score MUST NEVER be exposed to LLM-callable tools
17//! or any context surface the LLM can read. `/trajectory show` is an operator-only command.
18
19use std::collections::VecDeque;
20
21use zeph_config::TrajectorySentinelConfig;
22
23// Re-export config so callers only need one import.
24pub use zeph_config::TrajectorySentinelConfig as SentinelConfig;
25
26// ── Signal taxonomy ───────────────────────────────────────────────────────────
27
28#[non_exhaustive]
29/// Vigil confidence levels mirrored from the audit crate to avoid a circular dep.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum VigilRiskLevel {
32    /// Low-confidence injection match (reserved; current `VigilGate` does not emit this).
33    Low,
34    /// Medium-confidence injection match.
35    Medium,
36    /// High-confidence injection match.
37    High,
38}
39
40#[non_exhaustive]
41/// Risk signal emitted by security subsystems and accumulated by `TrajectorySentinel`.
42///
43/// Each variant maps to a configurable weight (see spec 050 §2 for defaults).
44/// `NovelTool` is deferred to Phase 2 and not present here.
45///
46/// # NEVER
47///
48/// Never expose signal values or the accumulated score to any LLM-callable surface.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum RiskSignal {
51    /// VIGIL flagged a tool output with the given confidence level.
52    VigilFlagged(VigilRiskLevel),
53    /// `PolicyEnforcer` denied a structured tool call.
54    PolicyDeny,
55    /// `ExfiltrationGuard` redacted at least one outbound URL or HTML img.
56    ExfiltrationRedaction,
57    /// Tool call rejected as out-of-scope by `ScopedToolExecutor`.
58    OutOfScope,
59    /// PII filter redacted ≥ 1 span in a tool output.
60    PiiRedaction,
61    /// Tool returned a non-zero exit code or unrecoverable error.
62    ToolFailure,
63    /// More than `high_call_rate_threshold` tool calls in the last 3 turns.
64    HighCallRate,
65    /// More than `unusual_read_threshold` distinct paths read in `window_turns`.
66    UnusualReadVolume,
67    /// A configured high-risk tool-pair transition occurred within K turns.
68    ToolPairTransition,
69    /// `RiskChainAccumulator` (`zeph-tools`) confirmed a `SensitiveRead -> NetworkEgress`
70    /// multi-step attack chain (`exfil_read_then_send`, code `10`).
71    ExfilReadThenSend,
72    /// `RiskChainAccumulator` (`zeph-tools`) confirmed a `CredentialAccess -> NetworkEgress`
73    /// multi-step attack chain (`cred_then_egress`, code `11`).
74    CredThenEgress,
75}
76
77impl RiskSignal {
78    /// Returns the default weight for this signal (configurable in Phase 2).
79    ///
80    /// Weights are finite and non-negative; this upholds the NEVER-negative-score invariant.
81    #[must_use]
82    pub fn default_weight(self) -> f32 {
83        match self {
84            // `RiskChainAccumulator` chain fires are confirmed, high-confidence multi-step
85            // attack patterns (not heuristics), so they weight at least as high as VIGIL's
86            // highest-confidence tier and `ExfiltrationRedaction`/`ToolPairTransition` —
87            // exfil_read_then_send weights highest, matching its higher `chain_bonus`
88            // (0.5 vs 0.4) in `zeph-tools`'s `risk_chain.rs`.
89            Self::ExfilReadThenSend | Self::VigilFlagged(VigilRiskLevel::High) => 2.5,
90            Self::CredThenEgress | Self::ExfiltrationRedaction | Self::ToolPairTransition => 2.0,
91            Self::VigilFlagged(VigilRiskLevel::Medium) => 1.0,
92            Self::PolicyDeny | Self::OutOfScope | Self::HighCallRate | Self::UnusualReadVolume => {
93                1.5
94            }
95            Self::PiiRedaction => 0.5,
96            // VigilFlagged(Low) and ToolFailure are both noisy low-weight signals.
97            Self::VigilFlagged(VigilRiskLevel::Low) | Self::ToolFailure => 0.3,
98        }
99    }
100}
101
102impl RiskSignal {
103    /// Convert a `u8` signal code from `RiskSignalSink` callbacks into a `RiskSignal`.
104    ///
105    /// Code table (mirrors the numeric constants used in `zeph-tools`):
106    /// - `1` = `PolicyDeny`
107    /// - `2` = `ExfiltrationRedaction`
108    /// - `3` = `OutOfScope`
109    /// - `4` = `PiiRedaction`
110    /// - `5` = `ToolFailure`
111    /// - `6` = `VigilFlagged(Medium)`
112    /// - `7` = `VigilFlagged(High)`
113    /// - `10` = `ExfilReadThenSend` (`RiskChainAccumulator`'s `exfil_read_then_send` chain)
114    /// - `11` = `CredThenEgress` (`RiskChainAccumulator`'s `cred_then_egress` chain)
115    /// - anything else = `VigilFlagged(Low)` (fallback)
116    #[must_use]
117    pub fn from_code(code: u8) -> Self {
118        match code {
119            1 => Self::PolicyDeny,
120            2 => Self::ExfiltrationRedaction,
121            3 => Self::OutOfScope,
122            4 => Self::PiiRedaction,
123            5 => Self::ToolFailure,
124            6 => Self::VigilFlagged(VigilRiskLevel::Medium),
125            7 => Self::VigilFlagged(VigilRiskLevel::High),
126            10 => Self::ExfilReadThenSend,
127            11 => Self::CredThenEgress,
128            _ => Self::VigilFlagged(VigilRiskLevel::Low),
129        }
130    }
131}
132
133// ── Risk levels ───────────────────────────────────────────────────────────────
134
135#[non_exhaustive]
136/// Advisory risk level computed from the accumulated score.
137///
138/// `PolicyGateExecutor` consumes this to decide whether to downgrade an `Allow` decision.
139///
140/// # LLM isolation
141///
142/// This enum MUST NOT appear in any tool output, slash-command response, or LLM context.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
144pub enum RiskLevel {
145    /// Score < `elevated_at`. Normal operation.
146    Calm,
147    /// Score in `[elevated_at, high_at)`. Audit tag only.
148    Elevated,
149    /// Score in `[high_at, critical_at)`. Audit tag + `RiskAlert` emitted.
150    High,
151    /// Score >= `critical_at`. `Allow` decisions downgraded to `Deny`.
152    Critical,
153}
154
155impl From<RiskLevel> for u8 {
156    fn from(level: RiskLevel) -> Self {
157        match level {
158            RiskLevel::Calm => 0,
159            RiskLevel::Elevated => 1,
160            RiskLevel::High => 2,
161            RiskLevel::Critical => 3,
162        }
163    }
164}
165
166// ── Risk alert ────────────────────────────────────────────────────────────────
167
168/// Emitted when the score crosses `alert_threshold`.
169///
170/// Consumed by `PolicyGateExecutor`. MUST NOT be observable by LLM-callable tools.
171#[derive(Debug, Clone, Copy)]
172pub struct RiskAlert {
173    /// Current risk level at alert time.
174    pub level: RiskLevel,
175    /// Accumulated score at alert time (rounded to two decimal places for logs).
176    pub score: f32,
177}
178
179// ── Sentinel ──────────────────────────────────────────────────────────────────
180
181/// Cross-turn risk accumulator for the advisory trajectory governance layer.
182///
183/// # Usage
184///
185/// ```rust
186/// use zeph_core::agent::trajectory::{TrajectorySentinel, RiskSignal, RiskLevel, VigilRiskLevel};
187/// use zeph_config::TrajectorySentinelConfig;
188///
189/// let mut sentinel = TrajectorySentinel::new(TrajectorySentinelConfig::default());
190///
191/// // Call advance_turn once per turn, BEFORE gate evaluation.
192/// let _ = sentinel.advance_turn();
193/// sentinel.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
194/// sentinel.record(RiskSignal::PolicyDeny);
195///
196/// let level = sentinel.current_risk();
197/// assert!(level >= RiskLevel::Calm);
198/// ```
199pub struct TrajectorySentinel {
200    cfg: TrajectorySentinelConfig,
201    /// Ring buffer of `(turn_number, signal)` pairs; evicted outside `window_turns`.
202    buf: VecDeque<(u64, RiskSignal)>,
203    current_turn: u64,
204    /// Turn on which the score last changed (for `advance_turn` dirty-tracking).
205    last_signal_turn: u64,
206    /// Cached sum; `None` means the buffer was mutated since the last computation.
207    cached_score: Option<f32>,
208    /// How many consecutive turns the sentinel has been at `>= Critical`.
209    critical_consecutive_turns: u32,
210}
211
212impl TrajectorySentinel {
213    /// Create a fresh sentinel with the given configuration.
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use zeph_core::agent::trajectory::TrajectorySentinel;
219    /// use zeph_config::TrajectorySentinelConfig;
220    ///
221    /// let sentinel = TrajectorySentinel::new(TrajectorySentinelConfig::default());
222    /// ```
223    #[must_use]
224    pub fn new(cfg: TrajectorySentinelConfig) -> Self {
225        Self {
226            cfg,
227            buf: VecDeque::new(),
228            current_turn: 0,
229            last_signal_turn: 0,
230            cached_score: Some(0.0),
231            critical_consecutive_turns: 0,
232        }
233    }
234
235    /// Initialise a child sentinel for a spawned subagent per FR-CG-011.
236    ///
237    /// When the parent is at `>= Elevated`, the child starts with a damped copy of the
238    /// parent's score (`parent_score * subagent_inheritance_factor`). This prevents
239    /// a subagent spawn from acting as a free risk reset.
240    ///
241    /// # Examples
242    ///
243    /// ```rust
244    /// use zeph_core::agent::trajectory::{TrajectorySentinel, RiskSignal, RiskLevel, VigilRiskLevel};
245    /// use zeph_config::TrajectorySentinelConfig;
246    ///
247    /// let mut parent = TrajectorySentinel::new(TrajectorySentinelConfig::default());
248    /// let _ = parent.advance_turn();
249    /// parent.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
250    /// parent.record(RiskSignal::PolicyDeny);
251    ///
252    /// let child = parent.spawn_child();
253    /// // Child starts with some inherited score when parent is >= Elevated.
254    /// ```
255    #[must_use]
256    pub fn spawn_child(&self) -> TrajectorySentinel {
257        let mut child = TrajectorySentinel::new(self.cfg.clone());
258        if self.current_risk() >= RiskLevel::Elevated {
259            let parent_score = self.score_now();
260            let damped = parent_score * self.cfg.subagent_inheritance_factor;
261            child.seed_score(damped);
262        }
263        child
264    }
265
266    /// Advance the turn counter and apply multiplicative decay.
267    ///
268    /// MUST be called once per turn, **before** any `PolicyGateExecutor::check_policy` runs.
269    /// Also handles the FR-CG-010 auto-recover cap: after `auto_recover_after_turns`
270    /// consecutive turns at `Critical` with no new high-weight signal, the score is hard-reset
271    /// to `0.0` and the buffer is cleared.
272    ///
273    /// Returns `true` when auto-recover fired this turn — the caller MUST write an audit entry
274    /// with `error_category = "trajectory_auto_recover"` (F5 requirement).
275    #[must_use]
276    pub fn advance_turn(&mut self) -> bool {
277        self.current_turn += 1;
278        self.cached_score = None; // score must be recomputed after decay
279
280        // Evict signals outside the window.
281        let window = u64::from(self.cfg.window_turns);
282        while let Some(&(turn, _)) = self.buf.front() {
283            if self.current_turn.saturating_sub(turn) >= window {
284                self.buf.pop_front();
285            } else {
286                break;
287            }
288        }
289
290        // Track Critical consecutive turns for auto-recover (FR-CG-010).
291        if self.current_risk() >= RiskLevel::Critical {
292            self.critical_consecutive_turns += 1;
293            let cap = self.cfg.auto_recover_after_turns.max(4); // floor at 4
294            if self.critical_consecutive_turns >= cap {
295                let score_at_reset = self.score_now();
296                let signal_census = self.buf.len();
297                tracing::warn!(
298                    score = score_at_reset,
299                    signal_count = signal_census,
300                    turns_at_critical = self.critical_consecutive_turns,
301                    "trajectory auto-recover: hard reset after {} consecutive Critical turns",
302                    cap
303                );
304                self.buf.clear();
305                self.cached_score = Some(0.0);
306                self.critical_consecutive_turns = 0;
307                return true;
308            }
309        } else {
310            self.critical_consecutive_turns = 0;
311        }
312        false
313    }
314
315    /// Record a risk signal for the current turn.
316    ///
317    /// # Examples
318    ///
319    /// ```rust
320    /// use zeph_core::agent::trajectory::{TrajectorySentinel, RiskSignal};
321    /// use zeph_config::TrajectorySentinelConfig;
322    ///
323    /// let mut sentinel = TrajectorySentinel::new(TrajectorySentinelConfig::default());
324    /// let _ = sentinel.advance_turn();
325    /// sentinel.record(RiskSignal::PolicyDeny);
326    /// assert!(sentinel.score_now() > 0.0);
327    /// ```
328    pub fn record(&mut self, sig: RiskSignal) {
329        self.buf.push_back((self.current_turn, sig));
330        self.cached_score = None;
331        self.last_signal_turn = self.current_turn;
332    }
333
334    /// Return the current risk level bucket for the accumulated score.
335    ///
336    /// # Examples
337    ///
338    /// ```rust
339    /// use zeph_core::agent::trajectory::{TrajectorySentinel, RiskLevel};
340    /// use zeph_config::TrajectorySentinelConfig;
341    ///
342    /// let sentinel = TrajectorySentinel::new(TrajectorySentinelConfig::default());
343    /// assert_eq!(sentinel.current_risk(), RiskLevel::Calm);
344    /// ```
345    #[must_use]
346    pub fn current_risk(&self) -> RiskLevel {
347        let score = self.score_now();
348        if score >= self.cfg.critical_at {
349            RiskLevel::Critical
350        } else if score >= self.cfg.high_at {
351            RiskLevel::High
352        } else if score >= self.cfg.elevated_at {
353            RiskLevel::Elevated
354        } else {
355            RiskLevel::Calm
356        }
357    }
358
359    /// Return a `RiskAlert` when the score crosses `alert_threshold`, `None` otherwise.
360    ///
361    /// Consumed by `PolicyGateExecutor`. Never expose to LLM-callable surfaces.
362    #[must_use]
363    pub fn poll_alert(&self) -> Option<RiskAlert> {
364        let score = self.score_now();
365        if score >= self.cfg.alert_threshold {
366            Some(RiskAlert {
367                level: self.current_risk(),
368                score,
369            })
370        } else {
371            None
372        }
373    }
374
375    /// Compute the decayed score from the signal buffer without mutating state.
376    ///
377    /// Score formula: `Σ_k decay_per_turn^(current_turn - signal_turn_k) * weight(signal_k)`
378    ///
379    /// Guaranteed to be finite and non-negative (upholds NEVER-negative invariant).
380    #[must_use]
381    pub fn score_now(&self) -> f32 {
382        if let Some(cached) = self.cached_score {
383            return cached;
384        }
385        let mut score: f32 = 0.0;
386        let decay = self.cfg.decay_per_turn;
387        for &(turn, signal) in &self.buf {
388            #[allow(clippy::cast_precision_loss)]
389            let age =
390                u32::try_from(self.current_turn.saturating_sub(turn)).unwrap_or(u32::MAX) as f32;
391            let contribution = decay.powf(age) * signal.default_weight();
392            score += contribution;
393        }
394        // Clamp to non-negative to satisfy the invariant (floating-point rounding safety).
395        score.max(0.0)
396    }
397
398    /// Hard reset: clear all state. Called on `/clear`, `/trajectory reset`, or session restart.
399    pub fn reset(&mut self) {
400        self.buf.clear();
401        self.cached_score = Some(0.0);
402        self.critical_consecutive_turns = 0;
403        self.last_signal_turn = 0;
404    }
405
406    /// Seed the sentinel with an initial score for subagent inheritance.
407    ///
408    /// Inserts a synthetic signal at turn 0 with the given weight. Only called
409    /// from `spawn_child` — not part of the normal signal path.
410    fn seed_score(&mut self, score: f32) {
411        debug_assert!(score >= 0.0, "seed score must be non-negative");
412        // Store a sentinel marker in the buffer so the seed participates in decay on the
413        // next advance_turn(). We encode it as (turn=0, PolicyDeny) × N where N is
414        // the number of PolicyDeny weights that sum to score. This is approximate but
415        // correct in terms of decay behavior.
416        let weight = RiskSignal::PolicyDeny.default_weight();
417        // Use floor to avoid overshooting the parent's score (P2 requirement).
418        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
419        let reps = (score / weight).floor() as usize;
420        for _ in 0..reps {
421            self.buf.push_back((0, RiskSignal::PolicyDeny));
422        }
423        self.cached_score = None; // will be recomputed from buf
424    }
425
426    /// The current turn counter (for diagnostics and audit logging only).
427    #[must_use]
428    pub fn current_turn(&self) -> u64 {
429        self.current_turn
430    }
431
432    /// Number of signals in the current window (for diagnostics only).
433    #[must_use]
434    pub fn signal_count(&self) -> usize {
435        self.buf.len()
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use zeph_config::TrajectorySentinelConfig;
443
444    fn default_sentinel() -> TrajectorySentinel {
445        TrajectorySentinel::new(TrajectorySentinelConfig::default())
446    }
447
448    #[test]
449    fn fresh_sentinel_is_calm() {
450        let s = default_sentinel();
451        assert_eq!(s.current_risk(), RiskLevel::Calm);
452        assert!(s.score_now().abs() < f32::EPSILON);
453    }
454
455    #[test]
456    fn single_policy_deny_elevates_score() {
457        let mut s = default_sentinel();
458        let _ = s.advance_turn();
459        s.record(RiskSignal::PolicyDeny);
460        // PolicyDeny weight = 1.5, elevated_at = 2.0 → still Calm
461        assert_eq!(s.current_risk(), RiskLevel::Calm);
462        assert!((s.score_now() - 1.5).abs() < 0.01);
463    }
464
465    #[test]
466    fn two_policy_denies_cross_elevated() {
467        let mut s = default_sentinel();
468        let _ = s.advance_turn();
469        s.record(RiskSignal::PolicyDeny);
470        s.record(RiskSignal::PolicyDeny);
471        // 1.5 + 1.5 = 3.0 >= elevated_at(2.0)
472        assert_eq!(s.current_risk(), RiskLevel::Elevated);
473    }
474
475    #[test]
476    fn vigil_high_signals_drive_to_critical() {
477        let mut s = default_sentinel();
478        // 6 × VigilFlagged(High) over 8 turns → acceptance test from spec
479        for _ in 0..6 {
480            let _ = s.advance_turn();
481            s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
482        }
483        // Σ_{k=0..5} 0.85^k × 2.5 ≈ 10.3 >= critical_at(8.0)
484        let score = s.score_now();
485        assert!(score >= 8.0, "expected score >= 8.0, got {score}");
486        assert_eq!(s.current_risk(), RiskLevel::Critical);
487    }
488
489    #[test]
490    fn advance_turn_before_gate_ordering() {
491        // Invariant 2: decay is applied at advance_turn, not at check time.
492        let mut s = default_sentinel();
493        let _ = s.advance_turn();
494        s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High)); // weight 2.5
495        let score_turn1 = s.score_now();
496        let _ = s.advance_turn();
497        let score_turn2 = s.score_now();
498        // After one idle turn, score decays by 0.85.
499        assert!(
500            score_turn2 < score_turn1,
501            "score must decay after advance_turn"
502        );
503        assert!((score_turn2 - score_turn1 * 0.85).abs() < 0.01);
504    }
505
506    #[test]
507    fn reset_clears_all_state() {
508        let mut s = default_sentinel();
509        let _ = s.advance_turn();
510        s.record(RiskSignal::PolicyDeny);
511        s.record(RiskSignal::PolicyDeny);
512        assert!(s.current_risk() >= RiskLevel::Elevated);
513        s.reset();
514        assert_eq!(s.current_risk(), RiskLevel::Calm);
515        assert!(s.score_now().abs() < f32::EPSILON);
516    }
517
518    #[test]
519    fn auto_recover_after_critical_turns_hard_reset() {
520        // decay_per_turn = 1.0 (no decay) and large window prevent score decay from
521        // masking the hard-reset code path.  Cap is 4 turns to keep the test fast.
522        let cfg = TrajectorySentinelConfig {
523            auto_recover_after_turns: 4,
524            window_turns: 30,
525            decay_per_turn: 1.0,
526            ..Default::default()
527        };
528        let mut s = TrajectorySentinel::new(cfg);
529
530        // Prime to Critical: 4 × VigilFlagged(High) (weight 2.5 × 4 = 10.0 > critical_at 8.0).
531        // With decay=1.0, score does not decay between turns.
532        for _ in 0..4 {
533            let _ = s.advance_turn();
534            s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
535        }
536        assert_eq!(
537            s.current_risk(),
538            RiskLevel::Critical,
539            "must be Critical before sustain loop"
540        );
541
542        // Each advance_turn in this loop sees Critical (score=10.0, no decay).
543        // critical_consecutive_turns increments each turn; hard-reset fires at turn 4.
544        let mut recovered = false;
545        for i in 0..4 {
546            let fired = s.advance_turn();
547            if fired {
548                recovered = true;
549                assert_eq!(
550                    i, 3,
551                    "hard-reset must fire on the 4th consecutive Critical turn, not turn {i}"
552                );
553                break;
554            }
555            assert_eq!(
556                s.current_risk(),
557                RiskLevel::Critical,
558                "must stay Critical during sustain loop (turn {i})"
559            );
560        }
561        assert!(
562            recovered,
563            "auto-recover hard-reset must fire after 4 consecutive Critical turns"
564        );
565        assert!(
566            s.current_risk() < RiskLevel::Critical,
567            "sentinel must be below Critical after hard-reset"
568        );
569        assert!(
570            s.score_now().abs() < f32::EPSILON,
571            "score must be 0 after hard-reset"
572        );
573    }
574
575    #[test]
576    fn score_never_negative() {
577        // Property: random Phase-1 signal traces must never produce negative score.
578        let mut s = default_sentinel();
579        for _ in 0..20 {
580            let _ = s.advance_turn();
581            s.record(RiskSignal::ToolFailure);
582            s.record(RiskSignal::PiiRedaction);
583            assert!(s.score_now() >= 0.0, "score became negative");
584        }
585    }
586
587    #[test]
588    fn score_never_nan() {
589        let mut s = default_sentinel();
590        for _ in 0..20 {
591            let _ = s.advance_turn();
592            s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
593            assert!(!s.score_now().is_nan(), "score became NaN");
594        }
595    }
596
597    #[test]
598    fn spawn_child_inherits_score_when_elevated() {
599        let mut parent = TrajectorySentinel::new(TrajectorySentinelConfig::default());
600        let _ = parent.advance_turn();
601        parent.record(RiskSignal::PolicyDeny);
602        parent.record(RiskSignal::PolicyDeny);
603        // parent at Elevated (score ~3.0)
604        assert!(parent.current_risk() >= RiskLevel::Elevated);
605        let child = parent.spawn_child();
606        assert!(
607            child.score_now() > 0.0,
608            "child must inherit non-zero score from elevated parent"
609        );
610        assert!(
611            child.score_now() < parent.score_now(),
612            "child score must be damped relative to parent"
613        );
614    }
615
616    #[test]
617    fn spawn_child_no_inheritance_when_calm() {
618        let parent = TrajectorySentinel::new(TrajectorySentinelConfig::default());
619        assert_eq!(parent.current_risk(), RiskLevel::Calm);
620        let child = parent.spawn_child();
621        assert!(
622            child.score_now().abs() < f32::EPSILON,
623            "calm parent must not seed child"
624        );
625    }
626
627    #[test]
628    fn poll_alert_fires_at_alert_threshold() {
629        let mut s = default_sentinel();
630        let _ = s.advance_turn();
631        // alert_threshold = 4.0; two VigilFlagged(High) at same turn = 5.0 >= 4.0
632        s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
633        s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
634        let alert = s.poll_alert();
635        assert!(alert.is_some(), "alert must fire at >= alert_threshold");
636    }
637
638    #[test]
639    fn window_evicts_old_signals() {
640        let cfg = TrajectorySentinelConfig {
641            window_turns: 3,
642            ..Default::default()
643        };
644        let mut s = TrajectorySentinel::new(cfg);
645        let _ = s.advance_turn();
646        s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High)); // turn 1
647        // Advance 3 more turns — the signal should be evicted.
648        let _ = s.advance_turn(); // turn 2
649        let _ = s.advance_turn(); // turn 3
650        let _ = s.advance_turn(); // turn 4 — turn 1 signal is now >= window_turns old
651        assert_eq!(
652            s.signal_count(),
653            0,
654            "signals outside window must be evicted"
655        );
656    }
657
658    #[test]
659    fn trajectory_config_validation_decay_bounds() {
660        let cfg_zero = TrajectorySentinelConfig {
661            decay_per_turn: 0.0,
662            ..Default::default()
663        };
664        assert!(
665            cfg_zero.validate().is_err(),
666            "decay=0.0 must fail validation"
667        );
668        let cfg_over = TrajectorySentinelConfig {
669            decay_per_turn: 1.1,
670            ..Default::default()
671        };
672        assert!(
673            cfg_over.validate().is_err(),
674            "decay>1.0 must fail validation"
675        );
676        let cfg_ok = TrajectorySentinelConfig {
677            decay_per_turn: 0.85,
678            ..Default::default()
679        };
680        assert!(cfg_ok.validate().is_ok());
681    }
682
683    #[test]
684    fn trajectory_config_validation_threshold_ordering() {
685        let cfg = TrajectorySentinelConfig {
686            elevated_at: 5.0,
687            high_at: 3.0, // violates elevated_at < high_at
688            ..Default::default()
689        };
690        assert!(cfg.validate().is_err());
691    }
692}