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