chronix_analytics/anomaly/
dynamic_threshold.rs1use crate::anomaly::error::AnomalyError;
12use crate::anomaly::traits::{validate_lengths, AnomalyDetector, AnomalyScore, DetectorType};
13
14#[derive(serde::Serialize, serde::Deserialize)]
18pub struct DynamicThresholdDetector {
19 k: f64,
20 lookback: usize,
21 period: Option<usize>,
24 baseline_mean: f64,
26 baseline_std: f64,
27 ring: Vec<f64>,
29 ring_sum: f64,
30 ring_sum_sq: f64,
31 ring_pos: usize,
32 ring_count: usize,
33 seasonal_phase: usize,
36 seasonal_means: Vec<f64>,
38 seasonal_stds: Vec<f64>,
39 fitted: bool,
40 points_since_refit: usize,
42 refit_interval: usize,
45}
46
47impl DynamicThresholdDetector {
48 pub fn new(lookback: Option<usize>, k: Option<f64>) -> Self {
55 let lb = lookback.unwrap_or(100).max(1);
56 Self {
57 k: k.unwrap_or(3.0),
58 lookback: lb,
59 period: None,
60 baseline_mean: 0.0,
61 baseline_std: 0.0,
62 ring: vec![0.0; lb],
63 ring_sum: 0.0,
64 ring_sum_sq: 0.0,
65 ring_pos: 0,
66 ring_count: 0,
67 seasonal_phase: 0,
68 seasonal_means: Vec::new(),
69 seasonal_stds: Vec::new(),
70 fitted: false,
71 points_since_refit: 0,
72 refit_interval: lb * 10,
73 }
74 }
75
76 pub fn with_period(lookback: Option<usize>, k: Option<f64>, period: usize) -> Self {
85 let mut det = Self::new(lookback, k);
86 det.period = Some(period);
87 det
88 }
89
90 fn build_seasonal_stats(&mut self, values: &[f64]) {
92 if let Some(period) = self.period {
93 let mut bucket_sums = vec![0.0; period];
94 let mut bucket_sum_sqs = vec![0.0; period];
95 let mut bucket_counts = vec![0usize; period];
96
97 for (i, &v) in values.iter().enumerate() {
98 let b = i % period;
99 bucket_sums[b] += v;
100 bucket_sum_sqs[b] += v * v;
101 bucket_counts[b] += 1;
102 }
103
104 self.seasonal_means = Vec::with_capacity(period);
105 self.seasonal_stds = Vec::with_capacity(period);
106
107 for b in 0..period {
108 let n = bucket_counts[b] as f64;
109 if n > 1.0 {
110 let mean = bucket_sums[b] / n;
111 let var = ((bucket_sum_sqs[b] - n * mean * mean) / (n - 1.0)).max(0.0);
112 self.seasonal_means.push(mean);
113 self.seasonal_stds.push(var.sqrt());
114 } else if n > 0.0 {
115 let mean = bucket_sums[b] / n;
116 self.seasonal_means.push(mean);
117 self.seasonal_stds.push(0.0);
118 } else {
119 self.seasonal_means.push(self.baseline_mean);
120 self.seasonal_stds.push(self.baseline_std);
121 }
122 }
123 }
124 }
125
126 #[inline]
127 fn normalize(raw: f64, threshold: f64) -> f64 {
128 let x = (raw - threshold) * 2.0;
129 1.0 / (1.0 + (-x).exp())
130 }
131}
132
133impl AnomalyDetector for DynamicThresholdDetector {
134 #[tracing::instrument(skip_all, level = "debug")]
135 fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
136 let _start = std::time::Instant::now();
137 if values.len() < self.lookback {
138 return Err(AnomalyError::InsufficientData {
139 min: self.lookback,
140 got: values.len(),
141 });
142 }
143
144 let n = values.len() as f64;
146 let mean = values.iter().sum::<f64>() / n;
147 let var = values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n;
148 self.baseline_mean = mean;
149 self.baseline_std = var.sqrt();
150
151 self.ring = vec![0.0; self.lookback];
153 self.ring_sum = 0.0;
154 self.ring_sum_sq = 0.0;
155 self.ring_pos = 0;
156 self.ring_count = 0;
157 let start = values.len().saturating_sub(self.lookback);
158 for &v in &values[start..] {
159 self.ring[self.ring_pos] = v;
160 self.ring_sum += v;
161 self.ring_sum_sq += v * v;
162 self.ring_pos = (self.ring_pos + 1) % self.lookback;
163 self.ring_count += 1;
164 }
165 self.build_seasonal_stats(values);
167
168 if let Some(p) = self.period {
170 self.seasonal_phase = values.len() % p;
171 }
172
173 self.fitted = true;
174 metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "dynamic_threshold").record(_start.elapsed().as_secs_f64());
175 Ok(())
176 }
177
178 #[tracing::instrument(skip_all, level = "debug")]
179 fn detect(
180 &mut self,
181 timestamps: &[i64],
182 values: &[f64],
183 ) -> Result<Vec<AnomalyScore>, AnomalyError> {
184 let _start = std::time::Instant::now();
185 validate_lengths(timestamps, values)?;
186 if !self.fitted {
187 return Err(AnomalyError::NotFitted);
188 }
189
190 let use_seasonal = self.period.is_some() && !self.seasonal_means.is_empty();
191 let mut scores = Vec::with_capacity(values.len());
192 let mut sum = 0.0;
193 let mut sum_sq = 0.0;
194
195 for (i, &v) in values.iter().enumerate() {
196 let (mean, std) = if use_seasonal {
198 let b = self
199 .period
200 .map(|p| (self.seasonal_phase + i) % p)
201 .unwrap_or(0);
202 (self.seasonal_means[b], self.seasonal_stds[b])
203 } else {
204 let count = i.min(self.lookback);
216 let (m, var) = if count >= self.lookback {
217 let c = count as f64;
218 let m = sum / c;
219 let var = (sum_sq / c) - m * m;
220 (m, var)
221 } else {
222 (self.baseline_mean, self.baseline_std * self.baseline_std)
223 };
224 sum += v;
226 sum_sq += v * v;
227 if i >= self.lookback {
228 sum -= values[i - self.lookback];
229 sum_sq -= values[i - self.lookback] * values[i - self.lookback];
230 }
231 if i > 0 && i % 1024 == 0 {
234 let start = (i + 1).saturating_sub(self.lookback);
235 sum = values[start..=i].iter().sum();
236 sum_sq = values[start..=i].iter().map(|&x| x * x).sum();
237 }
238 (m, var.max(0.0).sqrt())
239 };
240
241 let raw = if std < 1e-15 {
242 if (v - mean).abs() < 1e-15 {
243 0.0
244 } else {
245 f64::INFINITY
246 }
247 } else {
248 ((v - mean) / std).abs()
249 };
250 let is_anomaly = raw > self.k;
251 scores.push(AnomalyScore {
252 timestamp: timestamps[i],
253 value: v,
254 score: Self::normalize(raw, self.k),
255 is_anomaly,
256 method: DetectorType::DynamicThreshold,
257 threshold: self.k,
258 details: format!("rolling_mean={mean:.2} rolling_std={std:.4} z={raw:.4}"),
259 });
260 }
261 let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
262 metrics::counter!("chronix_anomaly_detected_total", "method" => "dynamic_threshold")
263 .increment(anomaly_count as u64);
264 metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "dynamic_threshold").record(_start.elapsed().as_secs_f64());
265
266 if let Some(period) = self.period {
268 if period > 0 {
269 self.seasonal_phase = (self.seasonal_phase + values.len()) % period;
270 }
271 }
272
273 Ok(scores)
274 }
275
276 fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
277 if !self.fitted {
278 return Err(AnomalyError::NotFitted);
279 }
280
281 let (mean, std) = if let Some(period) = self.period {
283 if !self.seasonal_means.is_empty() {
284 let b = self.seasonal_phase % period;
285 (self.seasonal_means[b], self.seasonal_stds[b])
286 } else {
287 (self.baseline_mean, self.baseline_std)
288 }
289 } else {
290 let count = self.ring_count.min(self.lookback);
293 if count >= self.lookback {
294 let c = count as f64;
295 let m = self.ring_sum / c;
296 let var = (self.ring_sum_sq / c) - m * m;
297 (m, var.max(0.0).sqrt())
298 } else {
299 (self.baseline_mean, self.baseline_std)
300 }
301 };
302
303 let raw = if std < 1e-15 {
304 if (value - mean).abs() < 1e-15 {
305 0.0
306 } else {
307 f64::INFINITY
308 }
309 } else {
310 ((value - mean) / std).abs()
311 };
312 let score = AnomalyScore {
313 timestamp,
314 value,
315 score: Self::normalize(raw, self.k),
316 is_anomaly: raw > self.k,
317 method: DetectorType::DynamicThreshold,
318 threshold: self.k,
319 details: format!("rolling_mean={mean:.2} rolling_std={std:.4} z={raw:.4}"),
320 };
321
322 if let Some(period) = self.period {
324 if period > 0 {
325 self.seasonal_phase = (self.seasonal_phase + 1) % period;
326 }
327 }
328
329 let evicted = self.ring[self.ring_pos];
332 self.ring[self.ring_pos] = value;
333 self.ring_pos = (self.ring_pos + 1) % self.lookback;
334 self.ring_count += 1;
335 self.ring_sum += value;
336 self.ring_sum_sq += value * value;
337 if self.ring_count > self.lookback {
338 self.ring_sum -= evicted;
339 self.ring_sum_sq -= evicted * evicted;
340 }
341
342 self.points_since_refit += 1;
346 if self.refit_interval > 0 && self.points_since_refit >= self.refit_interval {
347 let count = self.ring_count.min(self.lookback) as f64;
348 if count > 0.0 {
349 self.baseline_mean = self.ring_sum / count;
350 let var = (self.ring_sum_sq / count) - self.baseline_mean * self.baseline_mean;
351 self.baseline_std = var.max(0.0).sqrt();
352 }
353 self.points_since_refit = 0;
354 }
355
356 Ok(score)
357 }
358
359 fn detector_type(&self) -> DetectorType {
360 DetectorType::DynamicThreshold
361 }
362}
363
364#[cfg(test)]
365mod tests {
366
367 #[test]
376 fn a_filling_window_does_not_manufacture_anomalies() {
377 let n = 120;
378 let timestamps: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
379 let mut values: Vec<f64> = (0..n)
381 .map(|i| 20.0 + ((i as f64) * 0.05).sin() * 0.5)
382 .collect();
383 values[100] = 200.0;
384
385 let mut d = DynamicThresholdDetector::new(Some(30), Some(3.0));
386 d.fit(×tamps, &values).unwrap();
387 let scores = d.detect(×tamps, &values).unwrap();
388
389 for s in &scores {
390 assert!(
391 s.score.is_finite(),
392 "score must be finite, got {} at ts={} ({})",
393 s.score,
394 s.timestamp,
395 s.details
396 );
397 assert!(
398 !s.details.contains("z=inf"),
399 "a z-score of infinity reached the caller: {}",
400 s.details
401 );
402 }
403
404 let flagged: Vec<i64> = scores
405 .iter()
406 .filter(|s| s.is_anomaly)
407 .map(|s| s.timestamp)
408 .collect();
409 assert_eq!(
410 flagged,
411 vec![timestamps[100]],
412 "only the injected spike is anomalous"
413 );
414 }
415 use super::*;
416
417 #[test]
418 fn adapts_to_changing_baseline() {
419 let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
421 let mut vals: Vec<f64> = vec![50.0; 100];
422 vals.extend(vec![150.0; 100]);
423
424 let mut det = DynamicThresholdDetector::new(Some(50), Some(3.0));
425 det.fit(&ts, &vals).unwrap();
426 let scores = det.detect(&ts, &vals).unwrap();
427
428 let late_anomalies = scores[150..200].iter().filter(|s| s.is_anomaly).count();
430 assert!(
431 late_anomalies < 5,
432 "after adaptation, late anomalies should be low: {late_anomalies}"
433 );
434 }
435
436 #[test]
437 fn detect_point_spike() {
438 let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
439 let vals: Vec<f64> = vec![50.0; 200];
440 let mut det = DynamicThresholdDetector::new(Some(100), None);
441 det.fit(&ts, &vals).unwrap();
442 let normal = det.detect_point(999, 50.0).unwrap();
443 assert!(!normal.is_anomaly);
444 let spike = det.detect_point(1000, 500.0).unwrap();
445 assert!(spike.is_anomaly);
446 }
447
448 #[test]
449 fn insufficient_data() {
450 let mut det = DynamicThresholdDetector::new(Some(100), None);
451 let ts: Vec<i64> = (0..50).collect();
452 let vals: Vec<f64> = vec![1.0; 50];
453 assert!(det.fit(&ts, &vals).is_err());
454 }
455
456 #[test]
457 fn seasonal_awareness() {
458 let period = 24;
461 let n_cycles = 10; let n = period * n_cycles;
463 let ts: Vec<i64> = (0..n as i64).collect();
464 let vals: Vec<f64> = (0..n)
465 .map(|i| if i % period < 12 { 10.0 } else { 100.0 })
466 .collect();
467
468 let mut det = DynamicThresholdDetector::with_period(Some(50), Some(3.0), period);
469 det.fit(&ts, &vals).unwrap();
470
471 let test_ts: Vec<i64> = (n as i64..n as i64 + period as i64).collect();
473 let test_vals: Vec<f64> = (0..period)
474 .map(|i| if i < 12 { 10.0 } else { 100.0 })
475 .collect();
476 let scores = det.detect(&test_ts, &test_vals).unwrap();
477 let false_positives = scores.iter().filter(|s| s.is_anomaly).count();
478 assert_eq!(
479 false_positives, 0,
480 "seasonally normal values should not be flagged: got {false_positives}"
481 );
482
483 let spike = det.detect_point(9999, 500.0).unwrap();
485 assert!(spike.is_anomaly, "out-of-season spike should be flagged");
486 }
487
488 #[test]
489 fn ring_buffer_rolling_stats_converge() {
490 let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
494 let vals: Vec<f64> = vec![50.0; 200];
495 let mut det = DynamicThresholdDetector::new(Some(20), Some(3.0));
496 det.fit(&ts, &vals).unwrap();
497
498 for i in 0..50 {
500 det.detect_point(200 + i, 50.0).unwrap();
501 }
502
503 for i in 0..50 {
505 det.detect_point(250 + i, 200.0).unwrap();
506 }
507
508 let score = det.detect_point(300, 200.0).unwrap();
511 assert!(
512 !score.is_anomaly,
513 "after 50 points at 200 with lookback=20, 200.0 should be normal, \
514 but got score={:.3} is_anomaly={}",
515 score.score, score.is_anomaly
516 );
517 }
518
519 #[test]
520 fn seasonal_bucket_bessel_correction() {
521 let period = 4;
529 let lookback = 12;
530 let mut det = DynamicThresholdDetector::with_period(Some(lookback), Some(3.0), period);
531 let values = vec![
533 10.0, 50.0, 50.0, 50.0, 20.0, 50.0, 50.0, 50.0, 30.0, 50.0, 50.0, 50.0, ];
537 let ts: Vec<i64> = (0..values.len() as i64).collect();
538 det.fit(&ts, &values).unwrap();
539
540 let bucket0_std = det.seasonal_stds[0];
542 assert!(
543 (bucket0_std - 10.0).abs() < 1e-9,
544 "expected Bessel-corrected std ~10.0, got {bucket0_std:.6}"
545 );
546 }
547}