Skip to main content

alice_physics/
anomaly.rs

1//! Streaming Anomaly Detection
2//!
3//! Real-time outlier detection using robust statistics.
4//! Designed for telemetry streams where batch processing is not feasible.
5//!
6//! # Examples
7//!
8//! ```
9//! use alice_physics::anomaly::{MadDetector, EwmaDetector};
10//!
11//! // MAD-based outlier detection
12//! let mut mad = MadDetector::new(3.0);
13//! for &v in &[1.0, 2.0, 1.5, 1.8, 2.1, 1.9] {
14//!     mad.observe(v);
15//! }
16//! assert!(mad.is_anomaly(100.0)); // 100.0 is an outlier
17//!
18//! // EWMA-based detector
19//! let mut ewma = EwmaDetector::new(0.1, 3.0);
20//! for _ in 0..50 {
21//!     ewma.observe(10.0); // establish baseline
22//! }
23//! assert!(ewma.is_anomaly(1000.0)); // spike detected
24//! ```
25
26// ============================================================================
27// Streaming Median (for MAD calculation)
28// ============================================================================
29
30/// A threshold multiplier below zero is taken as zero (NaN is kept: it
31/// compares false and flags nothing).
32const fn non_negative(k: f64) -> f64 {
33    if k < 0.0 {
34        0.0
35    } else {
36        k
37    }
38}
39
40/// Default window size for streaming algorithms
41pub const DEFAULT_WINDOW: usize = 100;
42
43/// Streaming median using Sorted Rolling Window algorithm
44///
45/// Maintains a sorted array that is incrementally updated with O(N) memmove
46/// operations instead of O(N log N) sorting on each access.
47///
48/// - `push()`: O(N) - binary search + memmove
49/// - `median()`: O(1) - direct array access
50#[derive(Clone, Debug)]
51pub struct StreamingMedian {
52    /// Circular buffer of recent values (insertion order)
53    buffer: [f64; DEFAULT_WINDOW],
54    /// Sorted array maintained incrementally
55    sorted: [f64; DEFAULT_WINDOW],
56    /// Current write position in circular buffer
57    pos: usize,
58    /// Number of values seen (saturates at window size)
59    count: usize,
60}
61
62impl StreamingMedian {
63    /// Window size
64    pub const WINDOW: usize = DEFAULT_WINDOW;
65
66    /// Create a new streaming median estimator
67    #[must_use]
68    pub const fn new() -> Self {
69        Self {
70            buffer: [0.0; DEFAULT_WINDOW],
71            sorted: [0.0; DEFAULT_WINDOW],
72            pos: 0,
73            count: 0,
74        }
75    }
76
77    /// Binary search for insertion point in sorted array
78    #[inline]
79    fn binary_search_insert(&self, value: f64, len: usize) -> usize {
80        let mut left = 0;
81        let mut right = len;
82        while left < right {
83            let mid = left + (right - left) / 2;
84            if self.sorted[mid] < value {
85                left = mid + 1;
86            } else {
87                right = mid;
88            }
89        }
90        left
91    }
92
93    /// Binary search for removal point (finds exact match)
94    #[inline]
95    fn binary_search_remove(&self, value: f64, len: usize) -> Option<usize> {
96        let mut left = 0;
97        let mut right = len;
98        while left < right {
99            let mid = left + (right - left) / 2;
100            if self.sorted[mid] < value {
101                left = mid + 1;
102            } else if self.sorted[mid] > value {
103                right = mid;
104            } else {
105                return Some(mid);
106            }
107        }
108        // Handle floating point equality - search nearby
109        if left < len && (self.sorted[left] - value).abs() < f64::EPSILON {
110            return Some(left);
111        }
112        if left > 0 && (self.sorted[left - 1] - value).abs() < f64::EPSILON {
113            return Some(left - 1);
114        }
115        None
116    }
117
118    /// Add a new value with O(N) sorted array maintenance
119    ///
120    /// NaN is rejected and leaves the window unchanged: NaN cannot be
121    /// ordered against other values (every `<` / `>` comparison
122    /// involving it is `false`), which would otherwise corrupt the
123    /// invariant that `sorted` stays sorted, and the corruption would
124    /// outlive the NaN sample itself once it ages out of the window.
125    #[inline]
126    pub fn push(&mut self, value: f64) {
127        if value.is_nan() {
128            return;
129        }
130        if self.count < DEFAULT_WINDOW {
131            // Buffer not full: just insert into sorted array
132            let insert_pos = self.binary_search_insert(value, self.count);
133
134            // Shift elements right to make room (memmove)
135            if insert_pos < self.count {
136                self.sorted
137                    .copy_within(insert_pos..self.count, insert_pos + 1);
138            }
139            self.sorted[insert_pos] = value;
140
141            self.buffer[self.pos] = value;
142            self.pos = (self.pos + 1) % DEFAULT_WINDOW;
143            self.count += 1;
144        } else {
145            // Buffer full: remove old value, insert new value
146            let old_value = self.buffer[self.pos];
147
148            // Remove old value from sorted array
149            if let Some(remove_pos) = self.binary_search_remove(old_value, self.count) {
150                // Shift elements left (memmove)
151                if remove_pos < self.count - 1 {
152                    self.sorted
153                        .copy_within(remove_pos + 1..self.count, remove_pos);
154                }
155            }
156
157            // Insert new value into sorted array
158            let insert_pos = self.binary_search_insert(value, self.count - 1);
159
160            // Shift elements right to make room (memmove)
161            if insert_pos < self.count - 1 {
162                self.sorted
163                    .copy_within(insert_pos..self.count - 1, insert_pos + 1);
164            }
165            self.sorted[insert_pos] = value;
166
167            // Update circular buffer
168            self.buffer[self.pos] = value;
169            self.pos = (self.pos + 1) % DEFAULT_WINDOW;
170        }
171    }
172
173    /// Get the current median estimate - O(1)
174    #[inline]
175    pub fn median(&mut self) -> f64 {
176        if self.count == 0 {
177            return 0.0;
178        }
179
180        let mid = self.count / 2;
181        if self.count % 2 == 0 {
182            (self.sorted[mid - 1] + self.sorted[mid]) / 2.0
183        } else {
184            self.sorted[mid]
185        }
186    }
187
188    /// Get count of values
189    #[inline]
190    #[must_use]
191    pub const fn count(&self) -> usize {
192        self.count
193    }
194
195    /// Check if buffer is full
196    #[inline]
197    #[must_use]
198    pub const fn is_full(&self) -> bool {
199        self.count >= DEFAULT_WINDOW
200    }
201
202    /// Clear the buffer
203    pub fn clear(&mut self) {
204        self.buffer = [0.0; DEFAULT_WINDOW];
205        self.sorted = [0.0; DEFAULT_WINDOW];
206        self.pos = 0;
207        self.count = 0;
208    }
209}
210
211impl Default for StreamingMedian {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217// ============================================================================
218// MAD (Median Absolute Deviation) Detector
219// ============================================================================
220
221/// MAD-based anomaly detector
222///
223/// Uses Median Absolute Deviation for robust outlier detection.
224/// MAD = `median(|X_i` - median(X)|)
225///
226/// An observation is anomalous if:
227/// |x - median| > k * MAD * 1.4826
228///
229/// where 1.4826 is a scale factor for normal distributions,
230/// and k is typically 3 (for 3-sigma equivalent).
231///
232/// # Example
233/// ```
234/// use alice_physics::anomaly::MadDetector;
235///
236/// let mut detector = MadDetector::new(3.0);
237///
238/// // Train on normal data
239/// for v in [10.0, 11.0, 9.0, 10.5, 9.5, 10.2, 10.8] {
240///     detector.observe(v);
241/// }
242///
243/// // Check for anomalies
244/// assert!(!detector.is_anomaly(10.0)); // Normal
245/// assert!(detector.is_anomaly(100.0)); // Anomaly!
246/// ```
247#[derive(Clone, Debug)]
248pub struct MadDetector {
249    /// Streaming median for values
250    values_median: StreamingMedian,
251    /// Streaming median for absolute deviations
252    deviations_median: StreamingMedian,
253    /// Recent values for deviation calculation
254    recent_values: [f64; DEFAULT_WINDOW],
255    /// Position in recent values
256    pos: usize,
257    /// Count of observations
258    count: usize,
259    /// Threshold multiplier (k)
260    threshold_k: f64,
261    /// Cached median value
262    cached_median: f64,
263    /// Cached MAD value
264    cached_mad: f64,
265    /// Whether cache is valid
266    cache_valid: bool,
267}
268
269impl MadDetector {
270    /// Scale factor for normal distribution (1/Phi^-1(0.75))
271    const MAD_SCALE: f64 = 1.4826;
272
273    /// Create a new MAD detector
274    ///
275    /// # Arguments
276    /// * `threshold_k` - Number of MAD units for anomaly threshold (typically 3.0);
277    ///   a negative value is taken as 0 (a negative threshold flagged every
278    ///   sample, the median itself included)
279    #[must_use]
280    pub const fn new(threshold_k: f64) -> Self {
281        let threshold_k = non_negative(threshold_k);
282        Self {
283            values_median: StreamingMedian::new(),
284            deviations_median: StreamingMedian::new(),
285            recent_values: [0.0; DEFAULT_WINDOW],
286            pos: 0,
287            count: 0,
288            threshold_k,
289            cached_median: 0.0,
290            cached_mad: 0.0,
291            cache_valid: false,
292        }
293    }
294
295    /// Observe a new value (updates statistics)
296    ///
297    /// NaN is rejected outright: it is not stored in `recent_values` and
298    /// not pushed into `values_median`, so it never desynchronizes the
299    /// two and never reaches the deviation computation in this type's
300    /// private `update_cache` step.
301    pub fn observe(&mut self, value: f64) {
302        if value.is_nan() {
303            return;
304        }
305        // Store in recent values
306        self.recent_values[self.pos] = value;
307        self.pos = (self.pos + 1) % DEFAULT_WINDOW;
308        if self.count < DEFAULT_WINDOW {
309            self.count += 1;
310        }
311
312        // Update values median
313        self.values_median.push(value);
314
315        // Invalidate cache
316        self.cache_valid = false;
317    }
318
319    /// Update cached statistics
320    fn update_cache(&mut self) {
321        if self.cache_valid {
322            return;
323        }
324
325        // Get current median
326        self.cached_median = self.values_median.median();
327
328        // Calculate absolute deviations and their median
329        self.deviations_median.clear();
330        for i in 0..self.count {
331            let dev = (self.recent_values[i] - self.cached_median).abs();
332            self.deviations_median.push(dev);
333        }
334        self.cached_mad = self.deviations_median.median();
335
336        self.cache_valid = true;
337    }
338
339    /// Check if a value is an anomaly
340    pub fn is_anomaly(&mut self, value: f64) -> bool {
341        if self.count < 3 {
342            // Not enough data to determine anomalies
343            return false;
344        }
345
346        self.update_cache();
347
348        // Avoid division by zero
349        if self.cached_mad < 1e-10 {
350            // All values are the same, any different value is anomalous
351            return (value - self.cached_median).abs() > 1e-10;
352        }
353
354        let deviation = (value - self.cached_median).abs();
355        let threshold = self.threshold_k * self.cached_mad * Self::MAD_SCALE;
356
357        deviation > threshold
358    }
359
360    /// Get the anomaly score (higher = more anomalous)
361    ///
362    /// Returns the number of MAD units from the median.
363    pub fn anomaly_score(&mut self, value: f64) -> f64 {
364        if self.count < 3 {
365            return 0.0;
366        }
367
368        self.update_cache();
369
370        if self.cached_mad < 1e-10 {
371            return if (value - self.cached_median).abs() > 1e-10 {
372                f64::INFINITY
373            } else {
374                0.0
375            };
376        }
377
378        (value - self.cached_median).abs() / (self.cached_mad * Self::MAD_SCALE)
379    }
380
381    /// Get the current median
382    pub fn median(&mut self) -> f64 {
383        self.update_cache();
384        self.cached_median
385    }
386
387    /// Get the current MAD
388    pub fn mad(&mut self) -> f64 {
389        self.update_cache();
390        self.cached_mad
391    }
392
393    /// Get the threshold multiplier
394    #[inline]
395    #[must_use]
396    pub const fn threshold_k(&self) -> f64 {
397        self.threshold_k
398    }
399
400    /// Set the threshold multiplier
401    #[inline]
402    pub fn set_threshold_k(&mut self, k: f64) {
403        self.threshold_k = non_negative(k);
404    }
405
406    /// Get count of observations
407    #[inline]
408    #[must_use]
409    pub const fn count(&self) -> usize {
410        self.count
411    }
412
413    /// Clear all observations
414    pub fn clear(&mut self) {
415        self.values_median.clear();
416        self.deviations_median.clear();
417        self.recent_values = [0.0; DEFAULT_WINDOW];
418        self.pos = 0;
419        self.count = 0;
420        self.cache_valid = false;
421    }
422}
423
424// ============================================================================
425// EWMA (Exponentially Weighted Moving Average) Detector
426// ============================================================================
427
428/// EWMA-based anomaly detector
429///
430/// Uses exponentially weighted moving average and standard deviation
431/// for change point detection and anomaly flagging.
432///
433/// # Example
434/// ```
435/// use alice_physics::anomaly::EwmaDetector;
436///
437/// let mut detector = EwmaDetector::new(0.1, 3.0);
438///
439/// // Feed normal values
440/// for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1] {
441///     detector.observe(v);
442/// }
443///
444/// // Check for anomalies
445/// assert!(!detector.is_anomaly(10.0));
446/// assert!(detector.is_anomaly(50.0));
447/// ```
448#[derive(Clone, Debug)]
449pub struct EwmaDetector {
450    /// Smoothing factor (0 < alpha <= 1)
451    alpha: f64,
452    /// Current EWMA
453    ewma: f64,
454    /// EWMA of squared deviations (for variance)
455    ewma_var: f64,
456    /// Threshold multiplier (number of std devs)
457    threshold_k: f64,
458    /// Whether initialized
459    initialized: bool,
460    /// Count of observations
461    count: u64,
462}
463
464impl EwmaDetector {
465    /// Create a new EWMA detector
466    ///
467    /// # Arguments
468    /// * `alpha` - Smoothing factor (0.0-1.0, higher = more reactive)
469    /// * `threshold_k` - Number of standard deviations for anomaly threshold
470    ///   (a negative value is taken as 0)
471    #[must_use]
472    pub fn new(alpha: f64, threshold_k: f64) -> Self {
473        Self {
474            alpha: alpha.clamp(0.001, 1.0),
475            ewma: 0.0,
476            ewma_var: 0.0,
477            threshold_k: non_negative(threshold_k),
478            initialized: false,
479            count: 0,
480        }
481    }
482
483    /// Observe a new value and update statistics
484    ///
485    /// NaN is rejected: it is not counted and does not touch `ewma` /
486    /// `ewma_var`, since a NaN sample would otherwise poison the running
487    /// state for the life of the detector (every subsequent comparison
488    /// against a NaN `ewma` is `false`, so the detector would silently
489    /// stop flagging anything until [`Self::reset`]).
490    pub fn observe(&mut self, value: f64) {
491        if value.is_nan() {
492            return;
493        }
494        self.count += 1;
495
496        if !self.initialized {
497            self.ewma = value;
498            self.ewma_var = 0.0;
499            self.initialized = true;
500            return;
501        }
502
503        // Update EWMA
504        let deviation = value - self.ewma;
505        self.ewma += self.alpha * deviation;
506
507        // Update variance EWMA
508        self.ewma_var =
509            (1.0 - self.alpha) * (self.alpha * deviation).mul_add(deviation, self.ewma_var);
510    }
511
512    /// Check if a value is an anomaly (without updating)
513    #[must_use]
514    pub fn is_anomaly(&self, value: f64) -> bool {
515        if !self.initialized || self.count < 3 {
516            return false;
517        }
518
519        let std_dev = self.ewma_var.sqrt();
520        if std_dev < 1e-10 {
521            return (value - self.ewma).abs() > 1e-10;
522        }
523
524        let deviation = (value - self.ewma).abs();
525        deviation > self.threshold_k * std_dev
526    }
527
528    /// Get anomaly score (number of standard deviations)
529    #[must_use]
530    pub fn anomaly_score(&self, value: f64) -> f64 {
531        // the same three-sample warm-up as `is_anomaly` (a score of +inf after
532        // one sample contradicted the verdict)
533        if !self.initialized || self.count < 3 {
534            return 0.0;
535        }
536
537        let std_dev = self.ewma_var.sqrt();
538        if std_dev < 1e-10 {
539            return if (value - self.ewma).abs() > 1e-10 {
540                f64::INFINITY
541            } else {
542                0.0
543            };
544        }
545
546        (value - self.ewma).abs() / std_dev
547    }
548
549    /// Get current EWMA
550    #[inline]
551    #[must_use]
552    pub const fn ewma(&self) -> f64 {
553        self.ewma
554    }
555
556    /// Get current standard deviation estimate
557    #[inline]
558    #[must_use]
559    pub fn std_dev(&self) -> f64 {
560        self.ewma_var.sqrt()
561    }
562
563    /// Get smoothing factor
564    #[inline]
565    #[must_use]
566    pub const fn alpha(&self) -> f64 {
567        self.alpha
568    }
569
570    /// Set smoothing factor
571    #[inline]
572    pub fn set_alpha(&mut self, alpha: f64) {
573        self.alpha = alpha.clamp(0.001, 1.0);
574    }
575
576    /// Get threshold multiplier
577    #[inline]
578    #[must_use]
579    pub const fn threshold_k(&self) -> f64 {
580        self.threshold_k
581    }
582
583    /// Set threshold multiplier
584    #[inline]
585    pub fn set_threshold_k(&mut self, k: f64) {
586        self.threshold_k = non_negative(k);
587    }
588
589    /// Get count of observations
590    #[inline]
591    #[must_use]
592    pub const fn count(&self) -> u64 {
593        self.count
594    }
595
596    /// Reset the detector
597    pub fn reset(&mut self) {
598        self.ewma = 0.0;
599        self.ewma_var = 0.0;
600        self.initialized = false;
601        self.count = 0;
602    }
603}
604
605// ============================================================================
606// Z-Score Detector (Simple threshold-based)
607// ============================================================================
608
609/// Simple Z-score based anomaly detector
610///
611/// Maintains running mean and variance, flags values that exceed
612/// a threshold number of standard deviations.
613#[derive(Clone, Debug)]
614pub struct ZScoreDetector {
615    /// Running mean
616    mean: f64,
617    /// Running variance (using Welford's algorithm)
618    m2: f64,
619    /// Count of observations
620    count: u64,
621    /// Threshold (number of standard deviations)
622    threshold_k: f64,
623}
624
625impl ZScoreDetector {
626    /// Create a new Z-score detector (a negative `threshold_k` is taken as 0)
627    #[must_use]
628    pub const fn new(threshold_k: f64) -> Self {
629        Self {
630            mean: 0.0,
631            m2: 0.0,
632            count: 0,
633            threshold_k: non_negative(threshold_k),
634        }
635    }
636
637    /// Observe a new value (updates running statistics)
638    ///
639    /// NaN is rejected: it is not counted and does not touch `mean` /
640    /// `m2`, since a NaN sample would otherwise poison the running mean
641    /// permanently (every subsequent comparison against a NaN `mean` is
642    /// `false`, so the detector would silently stop flagging anything
643    /// until [`Self::reset`]).
644    pub fn observe(&mut self, value: f64) {
645        if value.is_nan() {
646            return;
647        }
648        self.count += 1;
649        let delta = value - self.mean;
650        self.mean += delta / self.count as f64;
651        let delta2 = value - self.mean;
652        self.m2 += delta * delta2;
653    }
654
655    /// Get the variance
656    #[inline]
657    #[must_use]
658    pub fn variance(&self) -> f64 {
659        if self.count < 2 {
660            0.0
661        } else {
662            self.m2 / (self.count - 1) as f64
663        }
664    }
665
666    /// Get the standard deviation
667    #[inline]
668    #[must_use]
669    pub fn std_dev(&self) -> f64 {
670        self.variance().sqrt()
671    }
672
673    /// Check if a value is an anomaly
674    #[must_use]
675    pub fn is_anomaly(&self, value: f64) -> bool {
676        if self.count < 3 {
677            return false;
678        }
679
680        let std_dev = self.std_dev();
681        if std_dev < 1e-10 {
682            return (value - self.mean).abs() > 1e-10;
683        }
684
685        let z_score = (value - self.mean).abs() / std_dev;
686        z_score > self.threshold_k
687    }
688
689    /// Get the Z-score for a value
690    #[must_use]
691    pub fn z_score(&self, value: f64) -> f64 {
692        // 0 during the three-sample warm-up of `is_anomaly` (there is no spread
693        // yet; the score was +inf even with no observation at all)
694        if self.count < 3 {
695            return 0.0;
696        }
697        let std_dev = self.std_dev();
698        if std_dev < 1e-10 {
699            return if (value - self.mean).abs() > 1e-10 {
700                f64::INFINITY
701            } else {
702                0.0
703            };
704        }
705        (value - self.mean) / std_dev
706    }
707
708    /// Get current mean
709    #[inline]
710    #[must_use]
711    pub const fn mean(&self) -> f64 {
712        self.mean
713    }
714
715    /// Get count
716    #[inline]
717    #[must_use]
718    pub const fn count(&self) -> u64 {
719        self.count
720    }
721
722    /// Reset the detector
723    pub fn reset(&mut self) {
724        self.mean = 0.0;
725        self.m2 = 0.0;
726        self.count = 0;
727    }
728}
729
730// ============================================================================
731// Anomaly Event
732// ============================================================================
733
734/// Represents a detected anomaly
735#[derive(Clone, Debug)]
736pub struct AnomalyEvent {
737    /// The anomalous value
738    pub value: f64,
739    /// Anomaly score (e.g., number of MAD/std devs)
740    pub score: f64,
741    /// Expected value (median or mean)
742    pub expected: f64,
743    /// Timestamp (if applicable)
744    pub timestamp: u64,
745    /// Metric identifier
746    pub metric_id: u64,
747}
748
749/// Callback for anomaly detection
750pub trait AnomalyCallback {
751    /// Called when an anomaly is detected
752    fn on_anomaly(&mut self, event: AnomalyEvent);
753}
754
755// ============================================================================
756// Composite Anomaly Detector
757// ============================================================================
758
759/// Composite detector that combines multiple detection methods
760///
761/// An observation is flagged as anomalous if ANY detector flags it.
762#[derive(Clone, Debug)]
763pub struct CompositeDetector {
764    /// MAD-based detector
765    pub mad: MadDetector,
766    /// EWMA-based detector
767    pub ewma: EwmaDetector,
768    /// Z-score detector
769    pub zscore: ZScoreDetector,
770    /// Whether to require all detectors to agree
771    pub require_consensus: bool,
772}
773
774impl CompositeDetector {
775    /// Create a new composite detector with default settings
776    #[must_use]
777    pub fn new() -> Self {
778        Self {
779            mad: MadDetector::new(3.0),
780            ewma: EwmaDetector::new(0.1, 3.0),
781            zscore: ZScoreDetector::new(3.0),
782            require_consensus: false,
783        }
784    }
785
786    /// Create with custom thresholds
787    #[must_use]
788    pub fn with_thresholds(mad_k: f64, ewma_alpha: f64, ewma_k: f64, zscore_k: f64) -> Self {
789        Self {
790            mad: MadDetector::new(mad_k),
791            ewma: EwmaDetector::new(ewma_alpha, ewma_k),
792            zscore: ZScoreDetector::new(zscore_k),
793            require_consensus: false,
794        }
795    }
796
797    /// Observe a new value (updates all detectors)
798    pub fn observe(&mut self, value: f64) {
799        self.mad.observe(value);
800        self.ewma.observe(value);
801        self.zscore.observe(value);
802    }
803
804    /// Check if a value is an anomaly
805    pub fn is_anomaly(&mut self, value: f64) -> bool {
806        let mad_anomaly = self.mad.is_anomaly(value);
807        let ewma_anomaly = self.ewma.is_anomaly(value);
808        let zscore_anomaly = self.zscore.is_anomaly(value);
809
810        if self.require_consensus {
811            // All must agree
812            mad_anomaly && ewma_anomaly && zscore_anomaly
813        } else {
814            // Any detector flags it
815            mad_anomaly || ewma_anomaly || zscore_anomaly
816        }
817    }
818
819    /// Get combined anomaly score (max of all scores)
820    pub fn anomaly_score(&mut self, value: f64) -> f64 {
821        let mad_score = self.mad.anomaly_score(value);
822        let ewma_score = self.ewma.anomaly_score(value);
823        let zscore_score = self.zscore.z_score(value).abs();
824
825        crate::det_math::max64(crate::det_math::max64(mad_score, ewma_score), zscore_score)
826    }
827
828    /// Get count of observations
829    #[inline]
830    #[must_use]
831    pub const fn count(&self) -> u64 {
832        self.zscore.count()
833    }
834
835    /// Reset all detectors
836    pub fn reset(&mut self) {
837        self.mad.clear();
838        self.ewma.reset();
839        self.zscore.reset();
840    }
841
842    /// Judge one sample against the statistics gathered so far, report it
843    /// through `callback` when it is anomalous, then absorb it.
844    ///
845    /// The verdict and the score are taken **before** the sample is observed,
846    /// so a spike is measured against the baseline it interrupts rather than
847    /// against a baseline it has already shifted. The event carries the
848    /// combined score ([`Self::anomaly_score`], the maximum over the three
849    /// detectors), the running median of the MAD detector as `expected`
850    /// (the robust centre this module is built around), and the caller's
851    /// `timestamp` / `metric_id` unchanged. Returns the verdict.
852    ///
853    /// The first two samples of a stream are never reported (every detector
854    /// withholds a verdict below three observations), and after
855    /// [`Self::reset`] the same holds again.
856    pub fn observe_with_callback<C: AnomalyCallback>(
857        &mut self,
858        value: f64,
859        timestamp: u64,
860        metric_id: u64,
861        callback: &mut C,
862    ) -> bool {
863        let flagged = self.is_anomaly(value);
864        if flagged {
865            let score = self.anomaly_score(value);
866            let expected = self.mad.median();
867            callback.on_anomaly(AnomalyEvent {
868                value,
869                score,
870                expected,
871                timestamp,
872                metric_id,
873            });
874        }
875        self.observe(value);
876        flagged
877    }
878}
879
880impl Default for CompositeDetector {
881    fn default() -> Self {
882        Self::new()
883    }
884}
885
886// ============================================================================
887// Tests
888// ============================================================================
889
890#[cfg(test)]
891#[allow(clippy::float_cmp)]
892mod tests {
893    use super::*;
894
895    #[test]
896    fn test_streaming_median() {
897        let mut sm = StreamingMedian::new();
898
899        for v in [5.0, 2.0, 8.0, 1.0, 9.0] {
900            sm.push(v);
901        }
902
903        let median = sm.median();
904        assert_eq!(median, 5.0); // Sorted: 1, 2, 5, 8, 9 -> median = 5
905    }
906
907    #[test]
908    fn test_streaming_median_even() {
909        let mut sm = StreamingMedian::new();
910
911        for v in [1.0, 2.0, 3.0, 4.0] {
912            sm.push(v);
913        }
914
915        let median = sm.median();
916        assert_eq!(median, 2.5); // Sorted: 1, 2, 3, 4 -> median = (2+3)/2 = 2.5
917    }
918
919    #[test]
920    fn test_mad_detector_normal() {
921        let mut detector = MadDetector::new(3.0);
922
923        // Add normal data centered around 10
924        for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9, 10.3, 9.7, 10.4] {
925            detector.observe(v);
926        }
927
928        // Normal values should not be anomalies
929        assert!(!detector.is_anomaly(10.0));
930        assert!(!detector.is_anomaly(10.5));
931        assert!(!detector.is_anomaly(9.5));
932
933        // Extreme values should be anomalies
934        assert!(detector.is_anomaly(100.0));
935        assert!(detector.is_anomaly(-50.0));
936    }
937
938    #[test]
939    fn test_ewma_detector() {
940        let mut detector = EwmaDetector::new(0.1, 3.0);
941
942        // Train on values with some variance
943        for i in 0..100 {
944            let value = 10.0 + (i % 3) as f64 - 1.0; // 9, 10, 11
945            detector.observe(value);
946        }
947
948        // Should be close to 10
949        assert!((detector.ewma() - 10.0).abs() < 1.0);
950
951        // Normal values (within trained range)
952        assert!(!detector.is_anomaly(10.0));
953        assert!(!detector.is_anomaly(11.0));
954
955        // Anomaly (far outside trained range)
956        assert!(detector.is_anomaly(50.0));
957    }
958
959    #[test]
960    fn test_zscore_detector() {
961        let mut detector = ZScoreDetector::new(3.0);
962
963        // Add normal data
964        for v in [10.0, 11.0, 9.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9, 10.3] {
965            detector.observe(v);
966        }
967
968        assert!((detector.mean() - 10.03).abs() < 0.01);
969
970        // Normal values
971        assert!(!detector.is_anomaly(10.0));
972
973        // Anomaly
974        assert!(detector.is_anomaly(100.0));
975    }
976
977    #[test]
978    fn test_composite_detector() {
979        let mut detector = CompositeDetector::new();
980
981        // Train on normal data
982        for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9, 10.3, 9.7, 10.4] {
983            detector.observe(v);
984        }
985
986        // Normal
987        assert!(!detector.is_anomaly(10.0));
988
989        // Anomaly
990        assert!(detector.is_anomaly(100.0));
991
992        let score = detector.anomaly_score(100.0);
993        assert!(score > 3.0);
994    }
995
996    #[test]
997    fn test_mad_detector_constant() {
998        let mut detector = MadDetector::new(3.0);
999
1000        // All same values
1001        for _ in 0..10 {
1002            detector.observe(5.0);
1003        }
1004
1005        // Same value is not anomaly
1006        assert!(!detector.is_anomaly(5.0));
1007
1008        // Different value is anomaly (MAD = 0)
1009        assert!(detector.is_anomaly(5.1));
1010    }
1011
1012    #[test]
1013    fn ewma_set_alpha_clamps_and_flips_detection() {
1014        // Clamp contract: alpha is confined to [0.001, 1.0].
1015        let mut d = EwmaDetector::new(0.1, 3.0);
1016        d.set_alpha(5.0);
1017        assert!((d.alpha() - 1.0).abs() < 1e-12, "alpha={}", d.alpha());
1018        d.set_alpha(0.0);
1019        assert!((d.alpha() - 0.001).abs() < 1e-12, "alpha={}", d.alpha());
1020        d.set_alpha(0.5);
1021        assert!((d.alpha() - 0.5).abs() < 1e-12, "alpha={}", d.alpha());
1022
1023        // Detection contract: same observation sequence, different alpha,
1024        // opposite verdicts. Sequence 10, 10, 100 with k = 3.
1025        //
1026        // alpha = 0.001 (slow):
1027        //   ewma = 10 + 0.001·90 = 10.09
1028        //   var  = 0.999·(0.001·90·90) = 8.0919 → std ≈ 2.8446
1029        //   threshold = 3·std ≈ 8.53 → 100 flagged, 10 not.
1030        let mut slow = EwmaDetector::new(0.1, 3.0);
1031        slow.set_alpha(0.001);
1032        for v in [10.0, 10.0, 100.0] {
1033            slow.observe(v);
1034        }
1035        assert!((slow.ewma() - 10.09).abs() < 1e-9, "ewma={}", slow.ewma());
1036        assert!(
1037            (slow.std_dev() - 8.0919_f64.sqrt()).abs() < 1e-9,
1038            "std={}",
1039            slow.std_dev()
1040        );
1041        assert!(slow.is_anomaly(100.0));
1042        assert!(!slow.is_anomaly(10.0));
1043
1044        // alpha = 1.0 (no memory): ewma tracks the last value exactly and
1045        // var = (1 - 1)·… = 0, so any deviation from 100 is anomalous.
1046        let mut fast = EwmaDetector::new(0.1, 3.0);
1047        fast.set_alpha(1.0);
1048        for v in [10.0, 10.0, 100.0] {
1049            fast.observe(v);
1050        }
1051        assert!((fast.ewma() - 100.0).abs() < 1e-12, "ewma={}", fast.ewma());
1052        assert!(fast.std_dev().abs() < 1e-12, "std={}", fast.std_dev());
1053        assert!(!fast.is_anomaly(100.0));
1054        assert!(fast.is_anomaly(10.0));
1055    }
1056
1057    #[test]
1058    fn ewma_set_threshold_k_changes_verdict_at_fixed_score() {
1059        // alpha = 0.5, sequence 10, 10, 12:
1060        //   after 3rd: ewma = 11, var = 0.5·(0.5·2·2) = 1 → std = 1
1061        // anomaly_score(13) = |13 - 11| / 1 = 2 exactly, independent of k.
1062        let mut d = EwmaDetector::new(0.5, 3.0);
1063        for v in [10.0, 10.0, 12.0] {
1064            d.observe(v);
1065        }
1066        assert!((d.std_dev() - 1.0).abs() < 1e-12, "std={}", d.std_dev());
1067        assert!((d.anomaly_score(13.0) - 2.0).abs() < 1e-12);
1068
1069        // k = 3 → 2 > 3 is false
1070        assert!(!d.is_anomaly(13.0));
1071        // k = 1.5 → 2 > 1.5 is true
1072        d.set_threshold_k(1.5);
1073        assert!((d.threshold_k() - 1.5).abs() < 1e-12);
1074        assert!(d.is_anomaly(13.0));
1075        // score is unchanged by k
1076        assert!((d.anomaly_score(13.0) - 2.0).abs() < 1e-12);
1077        // k = 2 → 2 > 2 is false (strict inequality)
1078        d.set_threshold_k(2.0);
1079        assert!(!d.is_anomaly(13.0));
1080    }
1081
1082    #[test]
1083    fn mad_set_threshold_k_changes_verdict_at_fixed_score() {
1084        // Values 1..=9: median = 5, |x - 5| = 4,3,2,1,0,1,2,3,4 → MAD = 2.
1085        // threshold = k · 2 · 1.4826 = 2.9652·k
1086        let mut d = MadDetector::new(3.0);
1087        for v in 1..=9 {
1088            d.observe(f64::from(v));
1089        }
1090        assert!((d.median() - 5.0).abs() < 1e-12, "median={}", d.median());
1091        assert!((d.mad() - 2.0).abs() < 1e-12, "mad={}", d.mad());
1092        // score(13) = 8 / (2 · 1.4826) — independent of k
1093        let score13 = 8.0 / (2.0 * 1.4826);
1094        assert!((d.anomaly_score(13.0) - score13).abs() < 1e-12);
1095
1096        // k = 3 → threshold 8.8956: 15 (dev 10) flagged, 13 (dev 8) not
1097        assert!(d.is_anomaly(15.0));
1098        assert!(!d.is_anomaly(13.0));
1099
1100        // k = 2 → threshold 5.9304: 13 now flagged
1101        d.set_threshold_k(2.0);
1102        assert!((d.threshold_k() - 2.0).abs() < 1e-12);
1103        assert!(d.is_anomaly(13.0));
1104        assert!((d.anomaly_score(13.0) - score13).abs() < 1e-12);
1105
1106        // k = 4 → threshold 11.8608: even 15 is no longer flagged
1107        d.set_threshold_k(4.0);
1108        assert!(!d.is_anomaly(15.0));
1109        assert!(!d.is_anomaly(13.0));
1110    }
1111
1112    #[test]
1113    fn composite_with_thresholds_propagates_each_parameter() {
1114        let c = CompositeDetector::with_thresholds(2.5, 0.25, 4.5, 1.0);
1115        assert!((c.mad.threshold_k() - 2.5).abs() < 1e-12);
1116        assert!((c.ewma.alpha() - 0.25).abs() < 1e-12);
1117        assert!((c.ewma.threshold_k() - 4.5).abs() < 1e-12);
1118        assert!(!c.require_consensus);
1119        assert_eq!(c.count(), 0);
1120
1121        // zscore_k has no getter; verify it by isolating the Z-score path:
1122        // make MAD / EWMA thresholds astronomically large so only the
1123        // Z-score detector can flag, then compare zscore_k = 1 vs 100.
1124        //
1125        // Observations 10, 11, 9, 10, 11, 9: mean = 10, sample variance =
1126        // (0+1+1+0+1+1)/5 = 0.8 → std ≈ 0.894; z(12) = 2/0.894 ≈ 2.236.
1127        let obs = [10.0, 11.0, 9.0, 10.0, 11.0, 9.0];
1128        let mut loose = CompositeDetector::with_thresholds(1e6, 0.1, 1e6, 100.0);
1129        let mut tight = CompositeDetector::with_thresholds(1e6, 0.1, 1e6, 1.0);
1130        for &v in &obs {
1131            loose.observe(v);
1132            tight.observe(v);
1133        }
1134        assert_eq!(loose.count(), 6);
1135        assert!((tight.zscore.mean() - 10.0).abs() < 1e-12);
1136        assert!((tight.zscore.variance() - 0.8).abs() < 1e-12);
1137        // Neither MAD nor EWMA fires at 12 with k = 1e6.
1138        assert!(!loose.mad.is_anomaly(12.0));
1139        assert!(!loose.ewma.is_anomaly(12.0));
1140        assert!(!tight.mad.is_anomaly(12.0));
1141        assert!(!tight.ewma.is_anomaly(12.0));
1142        // Only the Z-score threshold differs → only it decides the verdict.
1143        assert!(!loose.is_anomaly(12.0));
1144        assert!(tight.is_anomaly(12.0));
1145        assert!(tight.zscore.is_anomaly(12.0));
1146        assert!(!loose.zscore.is_anomaly(12.0));
1147    }
1148}