chronix_analytics/anomaly/traits.rs
1//! Core traits and types for anomaly detection.
2
3use serde::{Deserialize, Serialize};
4
5use crate::anomaly::error::AnomalyError;
6
7/// Which detection method produced a score.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub enum DetectorType {
10 /// Standard Z-score threshold.
11 ZScore,
12 /// Modified Z-score using median absolute deviation.
13 ModifiedZScore,
14 /// Interquartile range fence.
15 Iqr,
16 /// Forecast-residual based detection.
17 ForecastResidual,
18 /// Moving average residual based detection.
19 MovingAverageResidual,
20 /// Dynamic threshold detection.
21 DynamicThreshold,
22 /// CUSUM (Cumulative Sum) change-point detection.
23 Cusum,
24 /// Custom plugin detector identified by name.
25 Custom(String),
26}
27
28/// A single point's anomaly assessment.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct AnomalyScore {
31 /// Observation timestamp (nanoseconds).
32 pub timestamp: i64,
33 /// Observed value.
34 pub value: f64,
35 /// Normalized score in `[0.0, 1.0]` — higher means more anomalous.
36 pub score: f64,
37 /// Whether the point was classified as anomalous.
38 pub is_anomaly: bool,
39 /// Detection method that produced this score.
40 pub method: DetectorType,
41 /// Threshold used for classification.
42 pub threshold: f64,
43 /// Human-readable explanation.
44 pub details: String,
45}
46
47/// Anomaly detection contract.
48pub trait AnomalyDetector: Send + Sync {
49 /// Fit the detector on historical data.
50 fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError>;
51
52 /// Score every point in the provided data.
53 fn detect(
54 &mut self,
55 timestamps: &[i64],
56 values: &[f64],
57 ) -> Result<Vec<AnomalyScore>, AnomalyError>;
58
59 /// Score a single streaming point (O(1) after fitting).
60 fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError>;
61
62 /// What kind of detector this is.
63 fn detector_type(&self) -> DetectorType;
64}
65
66/// Validate that `timestamps` and `values` have the same length.
67///
68/// Call at the start of every `detect()` / `fit()` implementation to
69/// produce a clear `AnomalyError::InvalidInput` instead of a panic.
70#[inline]
71pub fn validate_lengths(timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
72 if timestamps.len() != values.len() {
73 return Err(AnomalyError::InvalidInput(format!(
74 "timestamps length ({}) != values length ({})",
75 timestamps.len(),
76 values.len(),
77 )));
78 }
79 Ok(())
80}
81
82/// A numerically and dimensionally sound floor for a dispersion estimate.
83///
84/// # Why a bare epsilon is not enough
85///
86/// Every z-score detector divides by a fitted σ. On a baseline with no
87/// variation — a quantised sensor, a gauge pinned at its limit, a counter
88/// that has not moved — σ is 0 and the quotient is `inf`, so a value one ULP
89/// away from the mean scores `inf` and is reported as an anomaly with
90/// `z=inf` in the details string. Substituting a fixed `1e-12` instead is
91/// dimensionally wrong: on a series measured in nanoseconds it is a
92/// perfectly ordinary spread, and on one measured in watts it makes 1e-13 of
93/// float noise a 0.1-σ event *or* a 10-σ one depending only on the unit.
94///
95/// The floor here is **relative to the level being measured**:
96/// `max(scale, |level|·1e-9, 1e-12)`. A billionth of the level is far below
97/// any real signal and far above f64 rounding noise (which is ~1e-16 of the
98/// level), so genuine shifts still score large while representation noise
99/// scores ~0. The absolute `1e-12` term keeps a level of exactly 0 finite.
100#[inline]
101#[must_use]
102pub fn scale_floor(scale: f64, level: f64) -> f64 {
103 const RELATIVE: f64 = 1e-9;
104 const ABSOLUTE: f64 = 1e-12;
105 scale.max(level.abs() * RELATIVE).max(ABSOLUTE)
106}