use crate::anomaly::error::AnomalyError;
use crate::anomaly::traits::{
AnomalyDetector, AnomalyScore, DetectorType, scale_floor, validate_lengths,
};
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MovingAverageResidualDetector {
window_size: usize,
threshold: f64,
residual_mean: f64,
residual_std: f64,
ring: Vec<f64>,
ring_sum: f64,
ring_pos: usize,
ring_count: usize,
since_recompute: u32,
fitted: bool,
}
impl MovingAverageResidualDetector {
pub fn new(window_size: Option<usize>, threshold: Option<f64>) -> Self {
let ws = window_size.unwrap_or(24).max(1);
Self {
window_size: ws,
threshold: threshold.unwrap_or(3.0),
residual_mean: 0.0,
residual_std: 0.0,
ring: vec![0.0; ws],
ring_sum: 0.0,
ring_pos: 0,
ring_count: 0,
since_recompute: 0,
fitted: false,
}
}
fn causal_ma_residuals(values: &[f64], window: usize) -> Vec<f64> {
let mut residuals = Vec::with_capacity(values.len().saturating_sub(1));
let mut sum = 0.0;
for (i, &v) in values.iter().enumerate() {
if i > 0 {
let count = i.min(window);
residuals.push(v - sum / count as f64);
}
sum += v;
if i >= window {
sum -= values[i - window];
}
if i > 0 && i % 1024 == 0 {
let start = (i + 1).saturating_sub(window);
sum = values[start..=i].iter().sum();
}
}
residuals
}
fn reset_window(&mut self) {
self.ring = vec![0.0; self.window_size];
self.ring_sum = 0.0;
self.ring_pos = 0;
self.ring_count = 0;
self.since_recompute = 0;
}
fn push(&mut self, value: f64) {
if self.ring_count >= self.window_size {
self.ring_sum -= self.ring[self.ring_pos];
} else {
self.ring_count += 1;
}
self.ring[self.ring_pos] = value;
self.ring_sum += value;
self.ring_pos = (self.ring_pos + 1) % self.window_size;
self.since_recompute += 1;
if self.since_recompute >= 1024 {
self.since_recompute = 0;
self.ring_sum = self.ring[..self.ring_count.min(self.window_size)]
.iter()
.sum();
}
}
#[inline]
fn score_residual(&self, residual: f64) -> f64 {
((residual - self.residual_mean) / scale_floor(self.residual_std, self.residual_mean)).abs()
}
#[inline]
fn normalize(raw: f64, threshold: f64) -> f64 {
let x = (raw - threshold) * 2.0;
1.0 / (1.0 + (-x).exp())
}
}
impl AnomalyDetector for MovingAverageResidualDetector {
fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
let _start = std::time::Instant::now();
if values.len() < self.window_size {
return Err(AnomalyError::InsufficientData {
min: self.window_size,
got: values.len(),
});
}
let all = Self::causal_ma_residuals(values, self.window_size);
let warm_up = self.window_size.saturating_sub(1).min(all.len());
let residuals = if all.len() - warm_up >= 2 {
&all[warm_up..]
} else {
&all[..]
};
let n = residuals.len() as f64;
self.residual_mean = residuals.iter().sum::<f64>() / n;
let denom = if n > 1.0 { n - 1.0 } else { n };
self.residual_std = (residuals
.iter()
.map(|&r| (r - self.residual_mean).powi(2))
.sum::<f64>()
/ denom)
.sqrt();
self.reset_window();
let start = values.len().saturating_sub(self.window_size);
for &v in &values[start..] {
self.push(v);
}
self.fitted = true;
metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "moving_average")
.record(_start.elapsed().as_secs_f64());
Ok(())
}
fn detect(
&mut self,
timestamps: &[i64],
values: &[f64],
) -> Result<Vec<AnomalyScore>, AnomalyError> {
let _start = std::time::Instant::now();
validate_lengths(timestamps, values)?;
if !self.fitted {
return Err(AnomalyError::NotFitted);
}
self.reset_window();
let mut scores = Vec::with_capacity(values.len());
for (i, &v) in values.iter().enumerate() {
scores.push(self.detect_point(timestamps[i], v)?);
}
let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
metrics::counter!("chronix_anomaly_detected_total", "method" => "moving_average")
.increment(anomaly_count as u64);
metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "moving_average").record(_start.elapsed().as_secs_f64());
Ok(scores)
}
fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
if !self.fitted {
return Err(AnomalyError::NotFitted);
}
let count = self.ring_count.min(self.window_size);
if count < self.window_size {
self.push(value);
return Ok(AnomalyScore {
timestamp,
value,
score: 0.0,
is_anomaly: false,
method: DetectorType::MovingAverageResidual,
threshold: self.threshold,
details: format!("warm-up {count}/{}", self.window_size),
});
}
let residual = value - self.ring_sum / count as f64;
let raw = self.score_residual(residual);
self.push(value);
Ok(AnomalyScore {
timestamp,
value,
score: Self::normalize(raw, self.threshold),
is_anomaly: raw > self.threshold,
method: DetectorType::MovingAverageResidual,
threshold: self.threshold,
details: format!("residual={residual:.4} z={raw:.4}"),
})
}
fn detector_type(&self) -> DetectorType {
DetectorType::MovingAverageResidual
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_sudden_shift() {
let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
let mut vals: Vec<f64> = vec![50.0; 200];
vals[100] = 200.0;
let mut det = MovingAverageResidualDetector::new(Some(24), Some(3.0));
det.fit(&ts, &vals).unwrap();
let scores = det.detect(&ts, &vals).unwrap();
assert!(scores[100].is_anomaly);
}
#[test]
fn gradual_drift_adapts() {
let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
let vals: Vec<f64> = (0..200).map(|i| 50.0 + i as f64 * 0.1).collect();
let mut det = MovingAverageResidualDetector::new(Some(24), Some(3.0));
det.fit(&ts, &vals).unwrap();
let scores = det.detect(&ts, &vals).unwrap();
let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
assert!(
anomaly_count < 10,
"too many false positives: {anomaly_count}"
);
}
#[test]
fn streaming_detect_point() {
let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
let vals: Vec<f64> = vec![50.0; 100];
let mut det = MovingAverageResidualDetector::new(Some(10), None);
det.fit(&ts, &vals).unwrap();
let normal = det.detect_point(999, 50.0).unwrap();
assert!(!normal.is_anomaly);
let spike = det.detect_point(1000, 500.0).unwrap();
assert!(spike.is_anomaly);
}
#[test]
fn insufficient_data() {
let mut det = MovingAverageResidualDetector::new(Some(24), None);
let ts: Vec<i64> = (0..10).collect();
let vals: Vec<f64> = vec![1.0; 10];
assert!(det.fit(&ts, &vals).is_err());
}
}