Skip to main content

chronix_analytics/anomaly/
dynamic_threshold.rs

1//! Dynamic Threshold anomaly detector.
2//!
3//! Uses a rolling window to compute adaptive mean and standard deviation
4//! so the threshold follows changes in the data distribution.
5//!
6//! When a `period` is configured, the detector groups historical data by
7//! time-of-day bucket (hour of day for period ≤ 24, or bucket index for
8//! larger periods) and compares each new point against the statistics of
9//! its matching bucket — making the detector seasonally aware.
10
11use crate::anomaly::error::AnomalyError;
12use crate::anomaly::traits::{AnomalyDetector, AnomalyScore, DetectorType, validate_lengths};
13
14/// Anomaly detector with an adaptive rolling threshold.
15///
16/// Supports optional seasonal awareness via hour-of-day bucketing.
17#[derive(serde::Serialize, serde::Deserialize)]
18pub struct DynamicThresholdDetector {
19    k: f64,
20    lookback: usize,
21    /// Optional period (number of data points per cycle). When set,
22    /// the detector uses per-bucket (hour-of-day) statistics.
23    period: Option<usize>,
24    // Fitted baseline stats (used for detect_point)
25    baseline_mean: f64,
26    baseline_std: f64,
27    // Ring buffer for streaming
28    ring: Vec<f64>,
29    ring_sum: f64,
30    ring_sum_sq: f64,
31    ring_pos: usize,
32    ring_count: usize,
33    /// Seasonal phase offset: the number of training data points mod period.
34    /// Used by `detect_point` to select the correct seasonal bucket.
35    seasonal_phase: usize,
36    // Seasonal buckets: per-bucket mean and std
37    seasonal_means: Vec<f64>,
38    seasonal_stds: Vec<f64>,
39    fitted: bool,
40    /// Number of points since last refit of the baseline.
41    points_since_refit: usize,
42    /// Refit the baseline from the ring buffer every N points.
43    /// Default: 10 × lookback. 0 disables automatic refit.
44    refit_interval: usize,
45}
46
47impl DynamicThresholdDetector {
48    /// Creates a new detector.
49    ///
50    /// - `lookback` — rolling window size (default 100).
51    /// - `k` — number of standard deviations for the threshold (default 3.0).
52    ///
53    /// A zero `lookback` is silently clamped to 1 to prevent modulo-by-zero.
54    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    /// Creates a new detector with seasonal awareness.
77    ///
78    /// - `lookback` — rolling window size (default 100).
79    /// - `k` — number of standard deviations for the threshold (default 3.0).
80    /// - `period` — number of data points per seasonal cycle (e.g. 24 for hourly
81    ///   data with daily seasonality). When set, the detector computes
82    ///   per-bucket statistics (same position within each cycle) and compares
83    ///   new points against their matching bucket.
84    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    /// Build per-bucket seasonal statistics from training data.
91    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        // Compute baseline from full training data
145        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        // Initialise ring buffer with last `lookback` values
152        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        // Build per-bucket seasonal statistics when period is configured
166        self.build_seasonal_stats(values);
167
168        // Track seasonal phase for detect_point's bucket selection
169        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            // Determine reference mean/std: prefer per-bucket seasonal stats
197            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                // Compute stats from the window BEFORE adding the current point,
205                // so the point being scored does not dilute its own z-score.
206                //
207                // The rolling window only speaks once it is full. A partial
208                // window is not a smaller window, it is a worse estimator:
209                // with one prior sample the variance is *exactly* zero, so
210                // the second point of every series scored `z = inf` and was
211                // reported as an anomaly — on every series, always. The
212                // sibling detector never had this because it scores against a
213                // residual std fixed at fit time. Until the window
214                // fills, the fitted baseline is the honest estimate.
215                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                // Now accumulate the current point for future iterations.
225                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                // Periodic FP drift recomputation every 1024 steps.
232                // Count-based strategy — see moving_average.rs for rationale.
233                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        // Advance seasonal phase so consecutive detect() calls use the correct offset.
267        if let Some(period) = self.period
268            && period > 0
269        {
270            self.seasonal_phase = (self.seasonal_phase + values.len()) % period;
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        // When seasonal period is configured, use per-bucket statistics
282        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            // Same rule as the batch path: a partial window is not an
291            // estimator, it is a source of zero-variance z-scores.
292            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        // Advance seasonal phase for next streaming call.
323        if let Some(period) = self.period
324            && period > 0
325        {
326            self.seasonal_phase = (self.seasonal_phase + 1) % period;
327        }
328
329        // Update ring buffer for non-seasonal rolling statistics.
330        // Save old value BEFORE overwriting so eviction subtracts the correct entry.
331        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        // Periodically refit the baseline from the ring buffer
343        // to track concept drift. Without this, the baseline_mean/std
344        // computed during initial fit() grows stale.
345        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    /// A detector must not report an anomaly because its window is still
368    /// filling.
369    ///
370    /// With one prior sample the rolling variance is exactly zero, so any
371    /// second value that differs at all scored `z = inf` — every series, every
372    /// time. It reached users: the bundled example printed
373    /// `rolling_std=0.0000 z=inf` as its first detection on ordinary data,
374    /// three points before anything unusual happened.
375    #[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        // Smooth, entirely unremarkable data with one genuine spike at the end.
380        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(&timestamps, &values).unwrap();
387        let scores = d.detect(&timestamps, &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        // Data: 100 points at ~50, then 100 points at ~150
420        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        // After adaptation, the later stable region should NOT be anomalous
429        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        // Create data with strong daily seasonality: period = 24
459        // Even-bucket values ≈ 10, odd-bucket values ≈ 100
460        let period = 24;
461        let n_cycles = 10; // 10 full cycles for training
462        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        // In-season values should NOT be anomalous regardless of position
472        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        // Out-of-season value in a low bucket should be anomalous
484        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        // Verify that detect_point's ring buffer rolling mean converges to the
491        // streaming values, not to a frozen initial state (regression test for
492        // Bug 1 Session 13 — eviction read-after-write).
493        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        // Feed 50 points at value=50 to fill the ring buffer
499        for i in 0..50 {
500            det.detect_point(200 + i, 50.0).unwrap();
501        }
502
503        // Now shift to value=200 for 50 more points
504        for i in 0..50 {
505            det.detect_point(250 + i, 200.0).unwrap();
506        }
507
508        // After 50 points at 200 (with lookback=20), the rolling mean should
509        // have fully converged to ~200. A value of 200 should not be anomalous.
510        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        // period=4, 25 full cycles → 25 observations per bucket
522        // Bucket 0 values alternate: 10, 20, 30, 10, 20, 30, ...
523        // With 25 values: 9×10 + 8×20 + 8×30 = 90 + 160 + 240 = 490, mean = 19.6
524        // Compute expected Bessel-corrected std manually below.
525        //
526        // Simpler: use 3 full cycles (12 values) with lookback=12
527        // bucket 0: [10, 20, 30] → mean=20, sample_var = 100, std=10
528        let period = 4;
529        let lookback = 12;
530        let mut det = DynamicThresholdDetector::with_period(Some(lookback), Some(3.0), period);
531        // 3 complete cycles, exactly 12 values — no leftover padding in bucket 0
532        let values = vec![
533            10.0, 50.0, 50.0, 50.0, // cycle 0: bucket0=10
534            20.0, 50.0, 50.0, 50.0, // cycle 1: bucket0=20
535            30.0, 50.0, 50.0, 50.0, // cycle 2: bucket0=30
536        ];
537        let ts: Vec<i64> = (0..values.len() as i64).collect();
538        det.fit(&ts, &values).unwrap();
539
540        // Bessel-corrected std for bucket 0: sqrt(100) = 10.0
541        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}