Skip to main content

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}