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}