chronix_analytics/anomaly/
cusum.rs1use crate::compute::simd_mean;
25
26use crate::anomaly::error::AnomalyError;
27use crate::anomaly::traits::{AnomalyDetector, AnomalyScore, DetectorType, validate_lengths};
28
29#[derive(serde::Serialize, serde::Deserialize)]
35pub struct CusumDetector {
36 h: f64,
38 k: f64,
41 target_mean: f64,
43 target_std: f64,
45 s_pos: f64,
47 s_neg: f64,
49 reset_after_alarm: bool,
56 fitted: bool,
58}
59
60impl CusumDetector {
61 pub fn new(h: Option<f64>, k: Option<f64>) -> Self {
66 Self {
67 h: h.unwrap_or(0.0), k: k.unwrap_or(0.0), target_mean: 0.0,
70 target_std: 0.0,
71 s_pos: 0.0,
72 s_neg: 0.0,
73 reset_after_alarm: true,
74 fitted: false,
75 }
76 }
77
78 #[must_use]
82 pub fn with_reset_after_alarm(mut self, reset: bool) -> Self {
83 self.reset_after_alarm = reset;
84 self
85 }
86
87 #[inline]
89 fn normalize(raw: f64, h: f64) -> f64 {
90 let x = (raw / h.max(1e-15) - 1.0) * 4.0;
91 1.0 / (1.0 + (-x).exp())
92 }
93}
94
95impl AnomalyDetector for CusumDetector {
96 fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
97 if values.len() < 10 {
98 return Err(AnomalyError::InsufficientData {
99 min: 10,
100 got: values.len(),
101 });
102 }
103 let _start = std::time::Instant::now();
104
105 self.target_mean = simd_mean(values);
106
107 let variance = values
109 .iter()
110 .map(|&v| (v - self.target_mean).powi(2))
111 .sum::<f64>()
112 / (values.len() - 1) as f64;
113 self.target_std = variance.sqrt().max(1e-15);
114
115 if self.h <= 0.0 {
120 self.h = (4.0 * self.target_std).max(1e-3);
121 }
122 if self.k <= 0.0 {
124 self.k = (0.5 * self.target_std).max(1e-4);
125 }
126
127 self.s_pos = 0.0;
129 self.s_neg = 0.0;
130 self.fitted = true;
131
132 metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "cusum")
133 .record(_start.elapsed().as_secs_f64());
134 Ok(())
135 }
136
137 fn detect(
138 &mut self,
139 timestamps: &[i64],
140 values: &[f64],
141 ) -> Result<Vec<AnomalyScore>, AnomalyError> {
142 validate_lengths(timestamps, values)?;
143 if !self.fitted {
144 return Err(AnomalyError::NotFitted);
145 }
146 let _start = std::time::Instant::now();
147
148 self.s_pos = 0.0;
150 self.s_neg = 0.0;
151
152 let mut scores = Vec::with_capacity(values.len());
153 for (i, &v) in values.iter().enumerate() {
154 let deviation = v - self.target_mean;
155 self.s_pos = (self.s_pos + deviation - self.k).max(0.0);
156 self.s_neg = (self.s_neg - deviation - self.k).max(0.0);
157
158 let raw = self.s_pos.max(self.s_neg);
159 let is_anomaly = raw > self.h;
160
161 let direction = if self.s_pos > self.s_neg {
162 "upward"
163 } else {
164 "downward"
165 };
166
167 scores.push(AnomalyScore {
168 timestamp: timestamps[i],
169 value: v,
170 score: Self::normalize(raw, self.h),
171 is_anomaly,
172 method: DetectorType::Cusum,
173 threshold: self.h,
174 details: format!(
175 "S+={:.4} S-={:.4} shift={direction}",
176 self.s_pos, self.s_neg
177 ),
178 });
179
180 if is_anomaly && self.reset_after_alarm {
182 self.s_pos = 0.0;
183 self.s_neg = 0.0;
184 }
185 }
186
187 let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
188 metrics::counter!("chronix_anomaly_detected_total", "method" => "cusum")
189 .increment(anomaly_count as u64);
190 metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "cusum")
191 .record(_start.elapsed().as_secs_f64());
192 Ok(scores)
193 }
194
195 fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
196 if !self.fitted {
197 return Err(AnomalyError::NotFitted);
198 }
199
200 let deviation = value - self.target_mean;
201 self.s_pos = (self.s_pos + deviation - self.k).max(0.0);
202 self.s_neg = (self.s_neg - deviation - self.k).max(0.0);
203
204 let raw = self.s_pos.max(self.s_neg);
205 let is_anomaly = raw > self.h;
206
207 let direction = if self.s_pos > self.s_neg {
208 "upward"
209 } else {
210 "downward"
211 };
212
213 let score = AnomalyScore {
214 timestamp,
215 value,
216 score: Self::normalize(raw, self.h),
217 is_anomaly,
218 method: DetectorType::Cusum,
219 threshold: self.h,
220 details: format!(
221 "S+={:.4} S-={:.4} shift={direction}",
222 self.s_pos, self.s_neg
223 ),
224 };
225
226 if is_anomaly && self.reset_after_alarm {
228 self.s_pos = 0.0;
229 self.s_neg = 0.0;
230 }
231
232 Ok(score)
233 }
234
235 fn detector_type(&self) -> DetectorType {
236 DetectorType::Cusum
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn noise(i: usize, amplitude: f64) -> f64 {
246 let mut z = (i as u64)
247 .wrapping_mul(0x9e3779b97f4a7c15)
248 .wrapping_add(0xbf58476d1ce4e5b9);
249 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
250 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
251 z = z ^ (z >> 31);
252 let u = (z as f64) / (u64::MAX as f64) * 2.0 - 1.0;
253 u * amplitude
254 }
255
256 #[test]
257 fn cusum_detects_mean_shift() {
258 let n = 200usize;
261 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
262 let mut vals: Vec<f64> = Vec::with_capacity(n);
263 for i in 0..n {
264 if i < 100 {
265 vals.push(100.0 + noise(i, 0.5));
266 } else {
267 vals.push(115.0 + noise(i, 0.5)); }
269 }
270
271 let mut det = CusumDetector::new(Some(5.0), Some(1.0));
272 det.fit(&ts[..100], &vals[..100]).unwrap();
273
274 let scores = det.detect(&ts, &vals).unwrap();
275 assert_eq!(scores.len(), n);
276
277 let anomalies_first_half: Vec<_> = scores[..100].iter().filter(|s| s.is_anomaly).collect();
279 assert!(
280 anomalies_first_half.is_empty(),
281 "no anomalies expected in stable portion, got {}",
282 anomalies_first_half.len()
283 );
284
285 let anomalies_second_half: Vec<_> = scores[100..].iter().filter(|s| s.is_anomaly).collect();
287 assert!(
288 !anomalies_second_half.is_empty(),
289 "expected at least one change point after mean shift"
290 );
291 }
292
293 #[test]
294 fn cusum_detects_downward_shift() {
295 let n = 200usize;
296 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
297 let mut vals: Vec<f64> = Vec::with_capacity(n);
298 for i in 0..n {
299 if i < 100 {
300 vals.push(100.0 + noise(i, 0.5));
301 } else {
302 vals.push(85.0 + noise(i, 0.5)); }
304 }
305
306 let mut det = CusumDetector::new(Some(5.0), Some(1.0));
307 det.fit(&ts[..100], &vals[..100]).unwrap();
308
309 let scores = det.detect(&ts, &vals).unwrap();
310 let second_half_anomalies: Vec<_> = scores[100..].iter().filter(|s| s.is_anomaly).collect();
311 assert!(
312 !second_half_anomalies.is_empty(),
313 "expected downward shift detection"
314 );
315 assert!(
316 second_half_anomalies
317 .iter()
318 .any(|s| s.details.contains("downward")),
319 "expected downward direction label"
320 );
321 }
322
323 #[test]
324 fn cusum_stable_no_false_alarms() {
325 let n = 200usize;
328 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
329 let vals: Vec<f64> = (0..n).map(|i| 50.0 + noise(i, 0.5)).collect();
330
331 let mut det = CusumDetector::new(Some(5.0), Some(1.0));
332 det.fit(&ts, &vals).unwrap();
333
334 let scores = det.detect(&ts, &vals).unwrap();
335 let anomalies: Vec<_> = scores.iter().filter(|s| s.is_anomaly).collect();
336 assert!(
337 anomalies.is_empty(),
338 "no anomalies expected on stationary data, got {}",
339 anomalies.len()
340 );
341 }
342
343 #[test]
344 fn cusum_streaming_detect_point() {
345 let n = 200usize;
346 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
347 let mut vals: Vec<f64> = Vec::with_capacity(n);
348 for i in 0..n {
349 if i < 100 {
350 vals.push(50.0 + noise(i, 0.3));
351 } else {
352 vals.push(65.0 + noise(i, 0.3)); }
354 }
355 let mut det = CusumDetector::new(Some(5.0), Some(1.0));
356 det.fit(&ts[..100], &vals[..100]).unwrap();
357
358 let mut detected_any = false;
359 for (i, &v) in vals.iter().enumerate() {
360 let score = det.detect_point(ts[i], v).unwrap();
361 if i >= 100 && score.is_anomaly {
362 detected_any = true;
363 }
364 }
365 assert!(detected_any, "streaming detect should catch the shift");
366 }
367
368 #[test]
369 fn cusum_insufficient_data() {
370 let mut det = CusumDetector::new(None, None);
371 let result = det.fit(&[1, 2, 3], &[1.0, 2.0, 3.0]);
372 assert!(result.is_err());
373 }
374
375 #[test]
376 fn cusum_custom_thresholds() {
377 let det = CusumDetector::new(Some(10.0), Some(2.0));
378 assert_eq!(det.h, 10.0);
379 assert_eq!(det.k, 2.0);
380 }
381
382 #[test]
383 fn cusum_not_fitted_errors() {
384 let mut det = CusumDetector::new(None, None);
385 let result = det.detect(&[1], &[1.0]);
386 assert!(matches!(result, Err(AnomalyError::NotFitted)));
387 let result = det.detect_point(1, 1.0);
388 assert!(matches!(result, Err(AnomalyError::NotFitted)));
389 }
390
391 #[test]
392 fn cusum_score_in_unit_range() {
393 let n = 200usize;
394 let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
395 let mut vals: Vec<f64> = Vec::with_capacity(n);
396 for i in 0..n {
397 if i < 100 {
398 vals.push(50.0 + noise(i, 0.3));
399 } else {
400 vals.push(65.0 + noise(i, 0.3));
401 }
402 }
403
404 let mut det = CusumDetector::new(None, None);
405 det.fit(&ts[..100], &vals[..100]).unwrap();
406
407 let scores = det.detect(&ts, &vals).unwrap();
408 for s in &scores {
409 assert!(
410 (0.0..=1.0).contains(&s.score),
411 "score {} out of [0,1] range",
412 s.score
413 );
414 }
415 }
416}