chronix_analytics/anomaly/moving_average.rs
1//! Moving Average Residual anomaly detector.
2//!
3//! Computes residuals against a **causal** sliding moving average — the mean
4//! of the `window_size` points *preceding* the one being scored — and flags
5//! points whose residual Z-Score exceeds the threshold.
6//!
7//! # One residual definition
8//!
9//! Excluding the scored point matters: for a window of `w`, including it
10//! shrinks the residual by a factor of `(w-1)/w` (25 % at `w = 5`), so a
11//! batch definition that includes it and a streaming definition that excludes
12//! it disagree on the z-score of the same point by that factor and flag
13//! different points. The causal form is the only one a streaming detector can
14//! compute, so it is the one both paths use, and [`detect`] is literally a
15//! fold of [`detect_point`].
16//!
17//! [`detect`]: AnomalyDetector::detect
18//! [`detect_point`]: AnomalyDetector::detect_point
19
20use crate::anomaly::error::AnomalyError;
21use crate::anomaly::traits::{
22 AnomalyDetector, AnomalyScore, DetectorType, scale_floor, validate_lengths,
23};
24
25/// Detector that compares values against their moving average.
26#[derive(serde::Serialize, serde::Deserialize)]
27pub struct MovingAverageResidualDetector {
28 window_size: usize,
29 threshold: f64,
30 // Fitted residual stats
31 residual_mean: f64,
32 residual_std: f64,
33 // Ring buffer for streaming
34 ring: Vec<f64>,
35 ring_sum: f64,
36 ring_pos: usize,
37 ring_count: usize,
38 /// Pushes since `ring_sum` was last recomputed from the ring itself.
39 since_recompute: u32,
40 fitted: bool,
41}
42
43impl MovingAverageResidualDetector {
44 /// Creates a new detector. `window_size` defaults to 24, `threshold` to 3.0.
45 ///
46 /// A zero `window_size` is silently clamped to 1 to prevent modulo-by-zero.
47 pub fn new(window_size: Option<usize>, threshold: Option<f64>) -> Self {
48 let ws = window_size.unwrap_or(24).max(1);
49 Self {
50 window_size: ws,
51 threshold: threshold.unwrap_or(3.0),
52 residual_mean: 0.0,
53 residual_std: 0.0,
54 ring: vec![0.0; ws],
55 ring_sum: 0.0,
56 ring_pos: 0,
57 ring_count: 0,
58 since_recompute: 0,
59 fitted: false,
60 }
61 }
62
63 /// Causal moving-average residuals: `r_i = v_i − mean(v_{i−w..i})`, the
64 /// average of the `w` points **before** `i`.
65 ///
66 /// Returns `values.len() − 1` residuals, for `i = 1..n`. `v_0` has no
67 /// preceding window and therefore no residual at all; reporting it as
68 /// `0.0` — which is what a window that includes the scored point does at
69 /// `i = 0` — puts a fabricated exact zero into the calibration sample,
70 /// pulling the fitted mean toward zero and shrinking the fitted spread.
71 fn causal_ma_residuals(values: &[f64], window: usize) -> Vec<f64> {
72 let mut residuals = Vec::with_capacity(values.len().saturating_sub(1));
73 let mut sum = 0.0;
74 for (i, &v) in values.iter().enumerate() {
75 if i > 0 {
76 let count = i.min(window);
77 residuals.push(v - sum / count as f64);
78 }
79 sum += v;
80 if i >= window {
81 sum -= values[i - window];
82 }
83 // Periodic FP drift recomputation every 1024 steps.
84 //
85 // This is a **count-based** recomputation strategy rather than
86 // an error-based one (e.g. Kahan summation or checking the
87 // accumulated drift magnitude). The fixed 1024-step interval
88 // is chosen for simplicity and predictable overhead:
89 // Keeps worst-case relative error below ~1e-12 for typical
90 // time-series magnitudes.
91 // Matches the strategy in RollingCorrelation and
92 // RollingStatExpr for consistency across the codebase.
93 // Avoids the per-step branch cost of error-based approaches.
94 if i > 0 && i % 1024 == 0 {
95 let start = (i + 1).saturating_sub(window);
96 sum = values[start..=i].iter().sum();
97 }
98 }
99 residuals
100 }
101
102 /// Empty the rolling window, so the next observation starts a stream.
103 fn reset_window(&mut self) {
104 self.ring = vec![0.0; self.window_size];
105 self.ring_sum = 0.0;
106 self.ring_pos = 0;
107 self.ring_count = 0;
108 self.since_recompute = 0;
109 }
110
111 /// Push one observation into the rolling window, evicting the oldest.
112 fn push(&mut self, value: f64) {
113 if self.ring_count >= self.window_size {
114 self.ring_sum -= self.ring[self.ring_pos];
115 } else {
116 self.ring_count += 1;
117 }
118 self.ring[self.ring_pos] = value;
119 self.ring_sum += value;
120 self.ring_pos = (self.ring_pos + 1) % self.window_size;
121
122 // Same count-based drift recomputation as the batch path.
123 self.since_recompute += 1;
124 if self.since_recompute >= 1024 {
125 self.since_recompute = 0;
126 self.ring_sum = self.ring[..self.ring_count.min(self.window_size)]
127 .iter()
128 .sum();
129 }
130 }
131
132 /// Absolute z-score of one residual. σ is floored relative to the
133 /// residual mean (see [`scale_floor`]) so a constant training window
134 /// scores float noise near 0 instead of at `inf`.
135 #[inline]
136 fn score_residual(&self, residual: f64) -> f64 {
137 ((residual - self.residual_mean) / scale_floor(self.residual_std, self.residual_mean)).abs()
138 }
139
140 #[inline]
141 fn normalize(raw: f64, threshold: f64) -> f64 {
142 let x = (raw - threshold) * 2.0;
143 1.0 / (1.0 + (-x).exp())
144 }
145}
146
147impl AnomalyDetector for MovingAverageResidualDetector {
148 fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
149 let _start = std::time::Instant::now();
150 if values.len() < self.window_size {
151 return Err(AnomalyError::InsufficientData {
152 min: self.window_size,
153 got: values.len(),
154 });
155 }
156
157 // Calibrate on full-window residuals only. The first `window − 1`
158 // are computed against a partial window, so they are systematically
159 // larger in absolute terms and are not comparable with the ones the
160 // detector will score. Including them made a pure linear drift look
161 // anomalous: the residual of a ramp is constant once the window is
162 // full, so the whole apparent spread came from the warm-up, and
163 // every steady-state point then sat several of those "sigmas" from
164 // the warm-up mean.
165 let all = Self::causal_ma_residuals(values, self.window_size);
166 let warm_up = self.window_size.saturating_sub(1).min(all.len());
167 let residuals = if all.len() - warm_up >= 2 {
168 &all[warm_up..]
169 } else {
170 &all[..]
171 };
172 let n = residuals.len() as f64;
173 self.residual_mean = residuals.iter().sum::<f64>() / n;
174 // Use Bessel's correction (n-1) for consistency with simd_std_dev
175 // and features::zscore across the codebase.
176 let denom = if n > 1.0 { n - 1.0 } else { n };
177 self.residual_std = (residuals
178 .iter()
179 .map(|&r| (r - self.residual_mean).powi(2))
180 .sum::<f64>()
181 / denom)
182 .sqrt();
183
184 // Seed the streaming window with the training tail, so a
185 // `detect_point` that continues the fitted series has a full window.
186 self.reset_window();
187 let start = values.len().saturating_sub(self.window_size);
188 for &v in &values[start..] {
189 self.push(v);
190 }
191 self.fitted = true;
192 metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "moving_average")
193 .record(_start.elapsed().as_secs_f64());
194 Ok(())
195 }
196
197 fn detect(
198 &mut self,
199 timestamps: &[i64],
200 values: &[f64],
201 ) -> Result<Vec<AnomalyScore>, AnomalyError> {
202 let _start = std::time::Instant::now();
203 validate_lengths(timestamps, values)?;
204 if !self.fitted {
205 return Err(AnomalyError::NotFitted);
206 }
207 // The first `window_size` points of the batch come back as
208 // "warm-up" and are never anomalies; see `detect_point`.
209 //
210 // A batch is scored as **its own** causal stream: the rolling
211 // window is reset and warmed from the batch's leading points, so
212 // point `i` is compared with the points before it in this batch.
213 //
214 // Without the reset the window carried over from `fit`, so
215 // re-scoring the training data compared its first points with the
216 // *end* of the series — on any trending series that is a residual
217 // the size of the whole trend, and every early point came back an
218 // anomaly. `detect` remains a fold of `detect_point`; the reset is
219 // what makes the fold start where the batch does.
220 self.reset_window();
221 let mut scores = Vec::with_capacity(values.len());
222 for (i, &v) in values.iter().enumerate() {
223 scores.push(self.detect_point(timestamps[i], v)?);
224 }
225 let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
226 metrics::counter!("chronix_anomaly_detected_total", "method" => "moving_average")
227 .increment(anomaly_count as u64);
228 metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "moving_average").record(_start.elapsed().as_secs_f64());
229 Ok(scores)
230 }
231
232 fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
233 if !self.fitted {
234 return Err(AnomalyError::NotFitted);
235 }
236 let count = self.ring_count.min(self.window_size);
237 // A point whose window is not yet full cannot be judged: its
238 // residual is computed against fewer neighbours than every residual
239 // the detector was calibrated on, so it is systematically smaller
240 // and comparing the two is a units error. The detector says
241 // "warming up" instead of inventing a verdict — which is what it
242 // used to do, flagging the first `window` points of every stream
243 // whose level was not flat.
244 if count < self.window_size {
245 self.push(value);
246 return Ok(AnomalyScore {
247 timestamp,
248 value,
249 score: 0.0,
250 is_anomaly: false,
251 method: DetectorType::MovingAverageResidual,
252 threshold: self.threshold,
253 details: format!("warm-up {count}/{}", self.window_size),
254 });
255 }
256 let residual = value - self.ring_sum / count as f64;
257 let raw = self.score_residual(residual);
258 // The window has to *move*. Without this the "moving" average stayed
259 // frozen at the last training window forever, so a stream that drifted
260 // away from its training level scored a residual that grew without
261 // bound and every point after the drift was an anomaly.
262 self.push(value);
263 Ok(AnomalyScore {
264 timestamp,
265 value,
266 score: Self::normalize(raw, self.threshold),
267 is_anomaly: raw > self.threshold,
268 method: DetectorType::MovingAverageResidual,
269 threshold: self.threshold,
270 details: format!("residual={residual:.4} z={raw:.4}"),
271 })
272 }
273
274 fn detector_type(&self) -> DetectorType {
275 DetectorType::MovingAverageResidual
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn detect_sudden_shift() {
285 let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
286 let mut vals: Vec<f64> = vec![50.0; 200];
287 // Sudden shift at point 100
288 vals[100] = 200.0;
289
290 let mut det = MovingAverageResidualDetector::new(Some(24), Some(3.0));
291 det.fit(&ts, &vals).unwrap();
292 let scores = det.detect(&ts, &vals).unwrap();
293 assert!(scores[100].is_anomaly);
294 }
295
296 #[test]
297 fn gradual_drift_adapts() {
298 let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
299 // Gradual linear drift
300 let vals: Vec<f64> = (0..200).map(|i| 50.0 + i as f64 * 0.1).collect();
301 let mut det = MovingAverageResidualDetector::new(Some(24), Some(3.0));
302 det.fit(&ts, &vals).unwrap();
303 let scores = det.detect(&ts, &vals).unwrap();
304 let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
305 // Gradual drift should not produce many anomalies
306 assert!(
307 anomaly_count < 10,
308 "too many false positives: {anomaly_count}"
309 );
310 }
311
312 #[test]
313 fn streaming_detect_point() {
314 let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
315 let vals: Vec<f64> = vec![50.0; 100];
316 let mut det = MovingAverageResidualDetector::new(Some(10), None);
317 det.fit(&ts, &vals).unwrap();
318 let normal = det.detect_point(999, 50.0).unwrap();
319 assert!(!normal.is_anomaly);
320 let spike = det.detect_point(1000, 500.0).unwrap();
321 assert!(spike.is_anomaly);
322 }
323
324 #[test]
325 fn insufficient_data() {
326 let mut det = MovingAverageResidualDetector::new(Some(24), None);
327 let ts: Vec<i64> = (0..10).collect();
328 let vals: Vec<f64> = vec![1.0; 10];
329 assert!(det.fit(&ts, &vals).is_err());
330 }
331}