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