Skip to main content

zeph_tools/
anomaly.rs

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