Skip to main content

chronix_analytics/anomaly/
storage.rs

1//! Anomaly detector serialization support.
2//!
3//! Re-uses the same `postcard`-based approach as the forecast `ModelStore`.
4
5use serde::{Deserialize, Serialize};
6
7/// Trait for serializing and deserializing fitted detectors.
8pub trait DetectorStore: Serialize + for<'de> Deserialize<'de> {
9    /// Serializes this detector to a byte vector.
10    fn to_bytes(&self) -> Result<Vec<u8>, DetectorStorageError> {
11        postcard::to_stdvec(self).map_err(|e| DetectorStorageError::Serialize(e.to_string()))
12    }
13
14    /// Deserializes a detector from bytes.
15    fn from_bytes(bytes: &[u8]) -> Result<Self, DetectorStorageError>
16    where
17        Self: Sized,
18    {
19        postcard::from_bytes(bytes).map_err(|e| DetectorStorageError::Deserialize(e.to_string()))
20    }
21}
22
23/// Errors from detector storage operations.
24#[derive(Debug, thiserror::Error)]
25pub enum DetectorStorageError {
26    #[error("serialization failed: {0}")]
27    Serialize(String),
28    #[error("deserialization failed: {0}")]
29    Deserialize(String),
30}
31
32// ─── ModelStore impls ───────────────────────────────────────────────
33
34impl DetectorStore for crate::anomaly::ZScoreDetector {}
35impl DetectorStore for crate::anomaly::ModifiedZScoreDetector {}
36impl DetectorStore for crate::anomaly::IqrDetector {}
37impl DetectorStore for crate::anomaly::MovingAverageResidualDetector {}
38impl DetectorStore for crate::anomaly::DynamicThresholdDetector {}
39// ForecastResidualDetector: serialize stats only, call refit() after deserialize
40impl DetectorStore for crate::anomaly::ForecastResidualDetector {}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use crate::anomaly::{AnomalyDetector, ZScoreDetector};
46
47    #[test]
48    fn zscore_round_trip() {
49        let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
50        let vals: Vec<f64> = (0..100).map(|i| 50.0 + (i as f64 * 0.1).sin()).collect();
51
52        let mut det = ZScoreDetector::new(Some(3.0));
53        det.fit(&ts, &vals).unwrap();
54
55        let bytes = det.to_bytes().unwrap();
56        let mut restored = ZScoreDetector::from_bytes(&bytes).unwrap();
57
58        // Verify same detection results
59        let orig = det.detect_point(999, 500.0).unwrap();
60        let rest = restored.detect_point(999, 500.0).unwrap();
61        assert_eq!(orig.is_anomaly, rest.is_anomaly);
62        assert!((orig.score - rest.score).abs() < 1e-10);
63    }
64
65    #[test]
66    fn iqr_round_trip() {
67        let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
68        let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
69
70        let mut det = crate::anomaly::IqrDetector::new(Some(1.5));
71        det.fit(&ts, &vals).unwrap();
72
73        let bytes = det.to_bytes().unwrap();
74        let mut restored = crate::anomaly::IqrDetector::from_bytes(&bytes).unwrap();
75
76        let orig = det.detect_point(999, 500.0).unwrap();
77        let rest = restored.detect_point(999, 500.0).unwrap();
78        assert_eq!(orig.is_anomaly, rest.is_anomaly);
79    }
80}