1use std::collections::VecDeque;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum AnomalySeverity {
11 Warning,
12 Critical,
13}
14
15#[derive(Debug, Clone)]
17pub struct Anomaly {
18 pub severity: AnomalySeverity,
19 pub description: String,
20}
21
22#[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 pub fn record_success(&mut self) {
51 self.push(Outcome::Success);
52 }
53
54 pub fn record_error(&mut self) {
56 self.push(Outcome::Error);
57 }
58
59 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 #[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 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 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}