use std::collections::VecDeque;
use zeph_config::TrajectorySentinelConfig;
pub use zeph_config::TrajectorySentinelConfig as SentinelConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VigilRiskLevel {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskSignal {
VigilFlagged(VigilRiskLevel),
PolicyDeny,
ExfiltrationRedaction,
OutOfScope,
PiiRedaction,
ToolFailure,
HighCallRate,
UnusualReadVolume,
ToolPairTransition,
}
impl RiskSignal {
#[must_use]
pub fn default_weight(self) -> f32 {
match self {
Self::VigilFlagged(VigilRiskLevel::High) => 2.5,
Self::VigilFlagged(VigilRiskLevel::Medium) => 1.0,
Self::ExfiltrationRedaction | Self::ToolPairTransition => 2.0,
Self::PolicyDeny | Self::OutOfScope | Self::HighCallRate | Self::UnusualReadVolume => {
1.5
}
Self::PiiRedaction => 0.5,
Self::VigilFlagged(VigilRiskLevel::Low) | Self::ToolFailure => 0.3,
}
}
}
impl RiskSignal {
#[must_use]
pub fn from_code(code: u8) -> Self {
match code {
1 => Self::PolicyDeny,
2 => Self::ExfiltrationRedaction,
3 => Self::OutOfScope,
4 => Self::PiiRedaction,
5 => Self::ToolFailure,
6 => Self::VigilFlagged(VigilRiskLevel::Medium),
7 => Self::VigilFlagged(VigilRiskLevel::High),
_ => Self::VigilFlagged(VigilRiskLevel::Low),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RiskLevel {
Calm,
Elevated,
High,
Critical,
}
impl From<RiskLevel> for u8 {
fn from(level: RiskLevel) -> Self {
match level {
RiskLevel::Calm => 0,
RiskLevel::Elevated => 1,
RiskLevel::High => 2,
RiskLevel::Critical => 3,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct RiskAlert {
pub level: RiskLevel,
pub score: f32,
}
pub struct TrajectorySentinel {
cfg: TrajectorySentinelConfig,
buf: VecDeque<(u64, RiskSignal)>,
current_turn: u64,
last_signal_turn: u64,
cached_score: Option<f32>,
critical_consecutive_turns: u32,
}
impl TrajectorySentinel {
#[must_use]
pub fn new(cfg: TrajectorySentinelConfig) -> Self {
Self {
cfg,
buf: VecDeque::new(),
current_turn: 0,
last_signal_turn: 0,
cached_score: Some(0.0),
critical_consecutive_turns: 0,
}
}
#[must_use]
pub fn spawn_child(&self) -> TrajectorySentinel {
let mut child = TrajectorySentinel::new(self.cfg.clone());
if self.current_risk() >= RiskLevel::Elevated {
let parent_score = self.score_now();
let damped = parent_score * self.cfg.subagent_inheritance_factor;
child.seed_score(damped);
}
child
}
#[must_use]
pub fn advance_turn(&mut self) -> bool {
self.current_turn += 1;
self.cached_score = None;
let window = u64::from(self.cfg.window_turns);
while let Some(&(turn, _)) = self.buf.front() {
if self.current_turn.saturating_sub(turn) >= window {
self.buf.pop_front();
} else {
break;
}
}
if self.current_risk() >= RiskLevel::Critical {
self.critical_consecutive_turns += 1;
let cap = self.cfg.auto_recover_after_turns.max(4); if self.critical_consecutive_turns >= cap {
let score_at_reset = self.score_now();
let signal_census = self.buf.len();
tracing::warn!(
score = score_at_reset,
signal_count = signal_census,
turns_at_critical = self.critical_consecutive_turns,
"trajectory auto-recover: hard reset after {} consecutive Critical turns",
cap
);
self.buf.clear();
self.cached_score = Some(0.0);
self.critical_consecutive_turns = 0;
return true;
}
} else {
self.critical_consecutive_turns = 0;
}
false
}
pub fn record(&mut self, sig: RiskSignal) {
self.buf.push_back((self.current_turn, sig));
self.cached_score = None;
self.last_signal_turn = self.current_turn;
}
#[must_use]
pub fn current_risk(&self) -> RiskLevel {
let score = self.score_now();
if score >= self.cfg.critical_at {
RiskLevel::Critical
} else if score >= self.cfg.high_at {
RiskLevel::High
} else if score >= self.cfg.elevated_at {
RiskLevel::Elevated
} else {
RiskLevel::Calm
}
}
#[must_use]
pub fn poll_alert(&self) -> Option<RiskAlert> {
let score = self.score_now();
if score >= self.cfg.alert_threshold {
Some(RiskAlert {
level: self.current_risk(),
score,
})
} else {
None
}
}
#[must_use]
pub fn score_now(&self) -> f32 {
if let Some(cached) = self.cached_score {
return cached;
}
let mut score: f32 = 0.0;
let decay = self.cfg.decay_per_turn;
for &(turn, signal) in &self.buf {
#[allow(clippy::cast_precision_loss)]
let age =
u32::try_from(self.current_turn.saturating_sub(turn)).unwrap_or(u32::MAX) as f32;
let contribution = decay.powf(age) * signal.default_weight();
score += contribution;
}
score.max(0.0)
}
pub fn reset(&mut self) {
self.buf.clear();
self.cached_score = Some(0.0);
self.critical_consecutive_turns = 0;
self.last_signal_turn = 0;
}
fn seed_score(&mut self, score: f32) {
debug_assert!(score >= 0.0, "seed score must be non-negative");
let weight = RiskSignal::PolicyDeny.default_weight();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let reps = (score / weight).floor() as usize;
for _ in 0..reps {
self.buf.push_back((0, RiskSignal::PolicyDeny));
}
self.cached_score = None; }
#[must_use]
pub fn current_turn(&self) -> u64 {
self.current_turn
}
#[must_use]
pub fn signal_count(&self) -> usize {
self.buf.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use zeph_config::TrajectorySentinelConfig;
fn default_sentinel() -> TrajectorySentinel {
TrajectorySentinel::new(TrajectorySentinelConfig::default())
}
#[test]
fn fresh_sentinel_is_calm() {
let s = default_sentinel();
assert_eq!(s.current_risk(), RiskLevel::Calm);
assert!(s.score_now().abs() < f32::EPSILON);
}
#[test]
fn single_policy_deny_elevates_score() {
let mut s = default_sentinel();
let _ = s.advance_turn();
s.record(RiskSignal::PolicyDeny);
assert_eq!(s.current_risk(), RiskLevel::Calm);
assert!((s.score_now() - 1.5).abs() < 0.01);
}
#[test]
fn two_policy_denies_cross_elevated() {
let mut s = default_sentinel();
let _ = s.advance_turn();
s.record(RiskSignal::PolicyDeny);
s.record(RiskSignal::PolicyDeny);
assert_eq!(s.current_risk(), RiskLevel::Elevated);
}
#[test]
fn vigil_high_signals_drive_to_critical() {
let mut s = default_sentinel();
for _ in 0..6 {
let _ = s.advance_turn();
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
}
let score = s.score_now();
assert!(score >= 8.0, "expected score >= 8.0, got {score}");
assert_eq!(s.current_risk(), RiskLevel::Critical);
}
#[test]
fn advance_turn_before_gate_ordering() {
let mut s = default_sentinel();
let _ = s.advance_turn();
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High)); let score_turn1 = s.score_now();
let _ = s.advance_turn();
let score_turn2 = s.score_now();
assert!(
score_turn2 < score_turn1,
"score must decay after advance_turn"
);
assert!((score_turn2 - score_turn1 * 0.85).abs() < 0.01);
}
#[test]
fn reset_clears_all_state() {
let mut s = default_sentinel();
let _ = s.advance_turn();
s.record(RiskSignal::PolicyDeny);
s.record(RiskSignal::PolicyDeny);
assert!(s.current_risk() >= RiskLevel::Elevated);
s.reset();
assert_eq!(s.current_risk(), RiskLevel::Calm);
assert!(s.score_now().abs() < f32::EPSILON);
}
#[test]
fn auto_recover_after_critical_turns_hard_reset() {
let cfg = TrajectorySentinelConfig {
auto_recover_after_turns: 4,
window_turns: 30,
decay_per_turn: 1.0,
..Default::default()
};
let mut s = TrajectorySentinel::new(cfg);
for _ in 0..4 {
let _ = s.advance_turn();
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
}
assert_eq!(
s.current_risk(),
RiskLevel::Critical,
"must be Critical before sustain loop"
);
let mut recovered = false;
for i in 0..4 {
let fired = s.advance_turn();
if fired {
recovered = true;
assert_eq!(
i, 3,
"hard-reset must fire on the 4th consecutive Critical turn, not turn {i}"
);
break;
}
assert_eq!(
s.current_risk(),
RiskLevel::Critical,
"must stay Critical during sustain loop (turn {i})"
);
}
assert!(
recovered,
"auto-recover hard-reset must fire after 4 consecutive Critical turns"
);
assert!(
s.current_risk() < RiskLevel::Critical,
"sentinel must be below Critical after hard-reset"
);
assert!(
s.score_now().abs() < f32::EPSILON,
"score must be 0 after hard-reset"
);
}
#[test]
fn score_never_negative() {
let mut s = default_sentinel();
for _ in 0..20 {
let _ = s.advance_turn();
s.record(RiskSignal::ToolFailure);
s.record(RiskSignal::PiiRedaction);
assert!(s.score_now() >= 0.0, "score became negative");
}
}
#[test]
fn score_never_nan() {
let mut s = default_sentinel();
for _ in 0..20 {
let _ = s.advance_turn();
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
assert!(!s.score_now().is_nan(), "score became NaN");
}
}
#[test]
fn spawn_child_inherits_score_when_elevated() {
let mut parent = TrajectorySentinel::new(TrajectorySentinelConfig::default());
let _ = parent.advance_turn();
parent.record(RiskSignal::PolicyDeny);
parent.record(RiskSignal::PolicyDeny);
assert!(parent.current_risk() >= RiskLevel::Elevated);
let child = parent.spawn_child();
assert!(
child.score_now() > 0.0,
"child must inherit non-zero score from elevated parent"
);
assert!(
child.score_now() < parent.score_now(),
"child score must be damped relative to parent"
);
}
#[test]
fn spawn_child_no_inheritance_when_calm() {
let parent = TrajectorySentinel::new(TrajectorySentinelConfig::default());
assert_eq!(parent.current_risk(), RiskLevel::Calm);
let child = parent.spawn_child();
assert!(
child.score_now().abs() < f32::EPSILON,
"calm parent must not seed child"
);
}
#[test]
fn poll_alert_fires_at_alert_threshold() {
let mut s = default_sentinel();
let _ = s.advance_turn();
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High));
let alert = s.poll_alert();
assert!(alert.is_some(), "alert must fire at >= alert_threshold");
}
#[test]
fn window_evicts_old_signals() {
let cfg = TrajectorySentinelConfig {
window_turns: 3,
..Default::default()
};
let mut s = TrajectorySentinel::new(cfg);
let _ = s.advance_turn();
s.record(RiskSignal::VigilFlagged(VigilRiskLevel::High)); let _ = s.advance_turn(); let _ = s.advance_turn(); let _ = s.advance_turn(); assert_eq!(
s.signal_count(),
0,
"signals outside window must be evicted"
);
}
#[test]
fn trajectory_config_validation_decay_bounds() {
let cfg_zero = TrajectorySentinelConfig {
decay_per_turn: 0.0,
..Default::default()
};
assert!(
cfg_zero.validate().is_err(),
"decay=0.0 must fail validation"
);
let cfg_over = TrajectorySentinelConfig {
decay_per_turn: 1.1,
..Default::default()
};
assert!(
cfg_over.validate().is_err(),
"decay>1.0 must fail validation"
);
let cfg_ok = TrajectorySentinelConfig {
decay_per_turn: 0.85,
..Default::default()
};
assert!(cfg_ok.validate().is_ok());
}
#[test]
fn trajectory_config_validation_threshold_ordering() {
let cfg = TrajectorySentinelConfig {
elevated_at: 5.0,
high_at: 3.0, ..Default::default()
};
assert!(cfg.validate().is_err());
}
}