1use std::collections::VecDeque;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum AnomalySeverity {
8 Warning,
9 Critical,
10}
11
12#[derive(Debug, Clone)]
14pub struct Anomaly {
15 pub severity: AnomalySeverity,
16 pub description: String,
17}
18
19#[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 pub fn record_success(&mut self) {
48 self.push(Outcome::Success);
49 }
50
51 pub fn record_error(&mut self) {
53 self.push(Outcome::Error);
54 }
55
56 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 #[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 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 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}