Skip to main content

zeph_tools/
anomaly.rs

1//! Sliding-window anomaly detection for tool execution patterns.
2
3use std::collections::VecDeque;
4
5/// Severity of a detected anomaly.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum AnomalySeverity {
8    Warning,
9    Critical,
10}
11
12/// A detected anomaly in tool execution patterns.
13#[derive(Debug, Clone)]
14pub struct Anomaly {
15    pub severity: AnomalySeverity,
16    pub description: String,
17}
18
19/// Tracks recent tool execution outcomes and detects anomalous patterns.
20#[derive(Debug)]
21pub struct AnomalyDetector {
22    window: VecDeque<Outcome>,
23    window_size: usize,
24    error_threshold: f64,
25    critical_threshold: f64,
26}
27
28#[derive(Debug, Clone, Copy)]
29enum Outcome {
30    Success,
31    Error,
32    Blocked,
33}
34
35impl AnomalyDetector {
36    #[must_use]
37    pub fn new(window_size: usize, error_threshold: f64, critical_threshold: f64) -> Self {
38        Self {
39            window: VecDeque::with_capacity(window_size),
40            window_size,
41            error_threshold,
42            critical_threshold,
43        }
44    }
45
46    /// Record a successful tool execution.
47    pub fn record_success(&mut self) {
48        self.push(Outcome::Success);
49    }
50
51    /// Record a failed tool execution.
52    pub fn record_error(&mut self) {
53        self.push(Outcome::Error);
54    }
55
56    /// Record a blocked tool execution.
57    pub fn record_blocked(&mut self) {
58        self.push(Outcome::Blocked);
59    }
60
61    fn push(&mut self, outcome: Outcome) {
62        if self.window.len() >= self.window_size {
63            self.window.pop_front();
64        }
65        self.window.push_back(outcome);
66    }
67
68    /// Check the current window for anomalies.
69    #[must_use]
70    #[allow(clippy::cast_precision_loss)]
71    pub fn check(&self) -> Option<Anomaly> {
72        if self.window.len() < 3 {
73            return None;
74        }
75
76        let total = self.window.len();
77        let errors = self
78            .window
79            .iter()
80            .filter(|o| matches!(o, Outcome::Error | Outcome::Blocked))
81            .count();
82
83        let ratio = errors as f64 / total as f64;
84
85        if ratio >= self.critical_threshold {
86            Some(Anomaly {
87                severity: AnomalySeverity::Critical,
88                description: format!(
89                    "error rate {:.0}% ({errors}/{total}) exceeds critical threshold",
90                    ratio * 100.0,
91                ),
92            })
93        } else if ratio >= self.error_threshold {
94            Some(Anomaly {
95                severity: AnomalySeverity::Warning,
96                description: format!(
97                    "error rate {:.0}% ({errors}/{total}) exceeds warning threshold",
98                    ratio * 100.0,
99                ),
100            })
101        } else {
102            None
103        }
104    }
105
106    /// Reset the sliding window.
107    pub fn reset(&mut self) {
108        self.window.clear();
109    }
110}
111
112impl Default for AnomalyDetector {
113    fn default() -> Self {
114        Self::new(10, 0.5, 0.8)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn no_anomaly_on_success() {
124        let mut det = AnomalyDetector::default();
125        for _ in 0..10 {
126            det.record_success();
127        }
128        assert!(det.check().is_none());
129    }
130
131    #[test]
132    fn warning_on_half_errors() {
133        let mut det = AnomalyDetector::new(10, 0.5, 0.8);
134        for _ in 0..5 {
135            det.record_success();
136        }
137        for _ in 0..5 {
138            det.record_error();
139        }
140        let anomaly = det.check().unwrap();
141        assert_eq!(anomaly.severity, AnomalySeverity::Warning);
142    }
143
144    #[test]
145    fn critical_on_high_errors() {
146        let mut det = AnomalyDetector::new(10, 0.5, 0.8);
147        for _ in 0..2 {
148            det.record_success();
149        }
150        for _ in 0..8 {
151            det.record_error();
152        }
153        let anomaly = det.check().unwrap();
154        assert_eq!(anomaly.severity, AnomalySeverity::Critical);
155    }
156
157    #[test]
158    fn blocked_counts_as_error() {
159        let mut det = AnomalyDetector::new(10, 0.5, 0.8);
160        for _ in 0..2 {
161            det.record_success();
162        }
163        for _ in 0..8 {
164            det.record_blocked();
165        }
166        let anomaly = det.check().unwrap();
167        assert_eq!(anomaly.severity, AnomalySeverity::Critical);
168    }
169
170    #[test]
171    fn window_slides() {
172        let mut det = AnomalyDetector::new(5, 0.5, 0.8);
173        for _ in 0..5 {
174            det.record_error();
175        }
176        assert!(det.check().is_some());
177
178        // Push 5 successes to slide out errors
179        for _ in 0..5 {
180            det.record_success();
181        }
182        assert!(det.check().is_none());
183    }
184
185    #[test]
186    fn too_few_samples_returns_none() {
187        let mut det = AnomalyDetector::default();
188        det.record_error();
189        det.record_error();
190        assert!(det.check().is_none());
191    }
192
193    #[test]
194    fn reset_clears_window() {
195        let mut det = AnomalyDetector::new(5, 0.5, 0.8);
196        for _ in 0..5 {
197            det.record_error();
198        }
199        assert!(det.check().is_some());
200        det.reset();
201        assert!(det.check().is_none());
202    }
203
204    #[test]
205    fn default_thresholds() {
206        let det = AnomalyDetector::default();
207        assert_eq!(det.window_size, 10);
208        assert!((det.error_threshold - 0.5).abs() < f64::EPSILON);
209        assert!((det.critical_threshold - 0.8).abs() < f64::EPSILON);
210    }
211}