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