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}