Skip to main content

alice_analytics/
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
6use crate::math::u64_f64;
7// a no_std unit-test build links std (libtest), whose inherent methods win;
8// FloatExt only covers correctly rounded operations, so the bits are the same
9#[cfg(not(any(feature = "std", test)))]
10use crate::math::FloatExt;
11
12// ============================================================================
13// Streaming Median (for MAD calculation)
14// ============================================================================
15
16/// Default window size for streaming algorithms
17pub const DEFAULT_WINDOW: usize = 100;
18
19/// Streaming median using Sorted Rolling Window algorithm
20///
21/// Maintains a sorted array that is incrementally updated with O(N) memmove
22/// operations instead of O(N log N) sorting on each access.
23///
24/// - `push()`: O(N) - binary search + memmove
25/// - `median()`: O(1) - direct array access
26#[derive(Clone, Debug)]
27pub struct StreamingMedian {
28    /// Circular buffer of recent values (insertion order)
29    buffer: [f64; DEFAULT_WINDOW],
30    /// Sorted array maintained incrementally
31    sorted: [f64; DEFAULT_WINDOW],
32    /// Current write position in circular buffer
33    pos: usize,
34    /// Number of values seen (saturates at window size)
35    count: usize,
36}
37
38impl StreamingMedian {
39    /// Window size
40    pub const WINDOW: usize = DEFAULT_WINDOW;
41
42    /// Create a new streaming median estimator
43    #[must_use]
44    pub const fn new() -> Self {
45        Self {
46            buffer: [0.0; DEFAULT_WINDOW],
47            sorted: [0.0; DEFAULT_WINDOW],
48            pos: 0,
49            count: 0,
50        }
51    }
52
53    /// Binary search for insertion point in sorted array
54    #[inline]
55    fn binary_search_insert(&self, value: f64, len: usize) -> usize {
56        let mut left = 0;
57        let mut right = len;
58        while left < right {
59            let mid = left + (right - left) / 2;
60            if self.sorted[mid] < value {
61                left = mid + 1;
62            } else {
63                right = mid;
64            }
65        }
66        left
67    }
68
69    /// Binary search for removal point (finds exact match)
70    #[inline]
71    fn binary_search_remove(&self, value: f64, len: usize) -> Option<usize> {
72        let mut left = 0;
73        let mut right = len;
74        while left < right {
75            let mid = left + (right - left) / 2;
76            if self.sorted[mid] < value {
77                left = mid + 1;
78            } else if self.sorted[mid] > value {
79                right = mid;
80            } else {
81                return Some(mid);
82            }
83        }
84        // Handle floating point equality - search nearby
85        if left < len && (self.sorted[left] - value).abs() < f64::EPSILON {
86            return Some(left);
87        }
88        if left > 0 && (self.sorted[left - 1] - value).abs() < f64::EPSILON {
89            return Some(left - 1);
90        }
91        None
92    }
93
94    /// Add a new value with O(N) sorted array maintenance
95    #[inline]
96    pub fn push(&mut self, value: f64) {
97        if self.count < DEFAULT_WINDOW {
98            // Buffer not full: just insert into sorted array
99            let insert_pos = self.binary_search_insert(value, self.count);
100
101            // Shift elements right to make room (memmove)
102            if insert_pos < self.count {
103                self.sorted
104                    .copy_within(insert_pos..self.count, insert_pos + 1);
105            }
106            self.sorted[insert_pos] = value;
107
108            self.buffer[self.pos] = value;
109            self.pos = (self.pos + 1) % DEFAULT_WINDOW;
110            self.count += 1;
111        } else {
112            // Buffer full: remove old value, insert new value
113            let old_value = self.buffer[self.pos];
114
115            // Remove old value from sorted array
116            if let Some(remove_pos) = self.binary_search_remove(old_value, self.count) {
117                // Shift elements left (memmove)
118                if remove_pos < self.count - 1 {
119                    self.sorted
120                        .copy_within(remove_pos + 1..self.count, remove_pos);
121                }
122            }
123
124            // Insert new value into sorted array
125            let insert_pos = self.binary_search_insert(value, self.count - 1);
126
127            // Shift elements right to make room (memmove)
128            if insert_pos < self.count - 1 {
129                self.sorted
130                    .copy_within(insert_pos..self.count - 1, insert_pos + 1);
131            }
132            self.sorted[insert_pos] = value;
133
134            // Update circular buffer
135            self.buffer[self.pos] = value;
136            self.pos = (self.pos + 1) % DEFAULT_WINDOW;
137        }
138    }
139
140    /// Get the current median estimate - O(1)
141    #[inline]
142    pub fn median(&mut self) -> f64 {
143        if self.count == 0 {
144            return 0.0;
145        }
146
147        let mid = self.count / 2;
148        if self.count.is_multiple_of(2) {
149            f64::midpoint(self.sorted[mid - 1], self.sorted[mid])
150        } else {
151            self.sorted[mid]
152        }
153    }
154
155    /// Get count of values
156    #[inline]
157    #[must_use]
158    pub const fn count(&self) -> usize {
159        self.count
160    }
161
162    /// Check if buffer is full
163    #[inline]
164    #[must_use]
165    pub const fn is_full(&self) -> bool {
166        self.count >= DEFAULT_WINDOW
167    }
168
169    /// Clear the buffer
170    pub const fn clear(&mut self) {
171        self.buffer = [0.0; DEFAULT_WINDOW];
172        self.sorted = [0.0; DEFAULT_WINDOW];
173        self.pos = 0;
174        self.count = 0;
175    }
176}
177
178impl Default for StreamingMedian {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184// ============================================================================
185// MAD (Median Absolute Deviation) Detector
186// ============================================================================
187
188/// MAD-based anomaly detector
189///
190/// Uses Median Absolute Deviation for robust outlier detection.
191/// MAD = `median(|X_i` - median(X)|)
192///
193/// An observation is anomalous if:
194/// |x - median| > k * MAD * 1.4826
195///
196/// where 1.4826 is a scale factor for normal distributions,
197/// and k is typically 3 (for 3-sigma equivalent).
198///
199/// # Example
200/// ```
201/// use alice_analytics::anomaly::MadDetector;
202///
203/// let mut detector = MadDetector::new(3.0);
204///
205/// // Train on normal data
206/// for v in [10.0, 11.0, 9.0, 10.5, 9.5, 10.2, 10.8] {
207///     detector.observe(v);
208/// }
209///
210/// // Check for anomalies
211/// assert!(!detector.is_anomaly(10.0)); // Normal
212/// assert!(detector.is_anomaly(100.0)); // Anomaly!
213/// ```
214#[derive(Clone, Debug)]
215pub struct MadDetector {
216    /// Streaming median for values
217    values_median: StreamingMedian,
218    /// Streaming median for absolute deviations
219    deviations_median: StreamingMedian,
220    /// Recent values for deviation calculation
221    recent_values: [f64; DEFAULT_WINDOW],
222    /// Position in recent values
223    pos: usize,
224    /// Count of observations
225    count: usize,
226    /// Threshold multiplier (k)
227    threshold_k: f64,
228    /// Cached median value
229    cached_median: f64,
230    /// Cached MAD value
231    cached_mad: f64,
232    /// Whether cache is valid
233    cache_valid: bool,
234}
235
236impl MadDetector {
237    /// Scale factor for normal distribution (1/Phi^-1(0.75))
238    const MAD_SCALE: f64 = 1.4826;
239
240    /// Create a new MAD detector
241    ///
242    /// # Arguments
243    /// * `threshold_k` - Number of MAD units for anomaly threshold (typically 3.0)
244    #[must_use]
245    pub const fn new(threshold_k: f64) -> Self {
246        Self {
247            values_median: StreamingMedian::new(),
248            deviations_median: StreamingMedian::new(),
249            recent_values: [0.0; DEFAULT_WINDOW],
250            pos: 0,
251            count: 0,
252            threshold_k,
253            cached_median: 0.0,
254            cached_mad: 0.0,
255            cache_valid: false,
256        }
257    }
258
259    /// Observe a new value (updates statistics)
260    pub fn observe(&mut self, value: f64) {
261        // Store in recent values
262        self.recent_values[self.pos] = value;
263        self.pos = (self.pos + 1) % DEFAULT_WINDOW;
264        if self.count < DEFAULT_WINDOW {
265            self.count += 1;
266        }
267
268        // Update values median
269        self.values_median.push(value);
270
271        // Invalidate cache
272        self.cache_valid = false;
273    }
274
275    /// Update cached statistics
276    fn update_cache(&mut self) {
277        if self.cache_valid {
278            return;
279        }
280
281        // Get current median
282        self.cached_median = self.values_median.median();
283
284        // Calculate absolute deviations and their median
285        self.deviations_median.clear();
286        for i in 0..self.count {
287            let dev = (self.recent_values[i] - self.cached_median).abs();
288            self.deviations_median.push(dev);
289        }
290        self.cached_mad = self.deviations_median.median();
291
292        self.cache_valid = true;
293    }
294
295    /// Check if a value is an anomaly
296    pub fn is_anomaly(&mut self, value: f64) -> bool {
297        if self.count < 3 {
298            // Not enough data to determine anomalies
299            return false;
300        }
301
302        self.update_cache();
303
304        // Avoid division by zero
305        if self.cached_mad < 1e-10 {
306            // All values are the same, any different value is anomalous
307            return (value - self.cached_median).abs() > 1e-10;
308        }
309
310        let deviation = (value - self.cached_median).abs();
311        let threshold = self.threshold_k * self.cached_mad * Self::MAD_SCALE;
312
313        deviation > threshold
314    }
315
316    /// Get the anomaly score (higher = more anomalous)
317    ///
318    /// Returns the number of MAD units from the median.
319    pub fn anomaly_score(&mut self, value: f64) -> f64 {
320        if self.count < 3 {
321            return 0.0;
322        }
323
324        self.update_cache();
325
326        if self.cached_mad < 1e-10 {
327            return if (value - self.cached_median).abs() > 1e-10 {
328                f64::INFINITY
329            } else {
330                0.0
331            };
332        }
333
334        let inv_mad_scale = 1.0 / (self.cached_mad * Self::MAD_SCALE);
335        (value - self.cached_median).abs() * inv_mad_scale
336    }
337
338    /// Get the current median
339    pub fn median(&mut self) -> f64 {
340        self.update_cache();
341        self.cached_median
342    }
343
344    /// Get the current MAD
345    pub fn mad(&mut self) -> f64 {
346        self.update_cache();
347        self.cached_mad
348    }
349
350    /// Get the threshold multiplier
351    #[inline]
352    #[must_use]
353    pub const fn threshold_k(&self) -> f64 {
354        self.threshold_k
355    }
356
357    /// Set the threshold multiplier
358    #[inline]
359    pub const fn set_threshold_k(&mut self, k: f64) {
360        self.threshold_k = k;
361    }
362
363    /// Get count of observations
364    #[inline]
365    #[must_use]
366    pub const fn count(&self) -> usize {
367        self.count
368    }
369
370    /// Clear all observations
371    pub const fn clear(&mut self) {
372        self.values_median.clear();
373        self.deviations_median.clear();
374        self.recent_values = [0.0; DEFAULT_WINDOW];
375        self.pos = 0;
376        self.count = 0;
377        self.cache_valid = false;
378    }
379}
380
381// ============================================================================
382// EWMA (Exponentially Weighted Moving Average) Detector
383// ============================================================================
384
385/// EWMA-based anomaly detector
386///
387/// Uses exponentially weighted moving average and standard deviation
388/// for change point detection and anomaly flagging.
389///
390/// # Example
391/// ```
392/// use alice_analytics::anomaly::EwmaDetector;
393///
394/// let mut detector = EwmaDetector::new(0.1, 3.0);
395///
396/// // Feed normal values
397/// for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1] {
398///     detector.observe(v);
399/// }
400///
401/// // Check for anomalies
402/// assert!(!detector.is_anomaly(10.0));
403/// assert!(detector.is_anomaly(50.0));
404/// ```
405#[derive(Clone, Debug)]
406pub struct EwmaDetector {
407    /// Smoothing factor (0 < alpha <= 1)
408    alpha: f64,
409    /// Current EWMA
410    ewma: f64,
411    /// EWMA of squared deviations (for variance)
412    ewma_var: f64,
413    /// Threshold multiplier (number of std devs)
414    threshold_k: f64,
415    /// Whether initialized
416    initialized: bool,
417    /// Count of observations
418    count: u64,
419}
420
421impl EwmaDetector {
422    /// Create a new EWMA detector
423    ///
424    /// # Arguments
425    /// * `alpha` - Smoothing factor in `(0.0, 1.0]`, higher = more reactive
426    /// * `threshold_k` - Number of standard deviations for anomaly threshold
427    ///
428    /// # Panics
429    ///
430    /// `alpha` が `(0.0, 1.0]` の外 (`NaN` / 無限大を含む、どちらも比較が偽に
431    /// なるので同じ assert で弾かれる) の場合
432    ///
433    /// 定義域の扱いは同 crate の
434    /// [`ExponentialMovingAverage::new`](crate::streaming_ops::ExponentialMovingAverage::new)
435    /// に合わせた (範囲外は黙って丸めず panic する) 2026-10-07 までは
436    /// `alpha.clamp(0.001, 1.0)` だったので、`0.0` と負値が黙って `0.001` になり
437    /// (⚠️ `-1.0` と `0.0` が同一結果になっていた)、**`NaN` は `clamp` が `NaN` を
438    /// 返すので丸められずに残り**、以後 `ewma` / `std_dev` / `anomaly_score` が
439    /// すべて `NaN` になって **`is_anomaly` が常に `false`** を返していた
440    /// = 検出器が黙って無効化される 無限大の score は「異常」と読めるが、
441    /// `false` は「正常」と読めるので、こちらの方が害が大きい
442    #[must_use]
443    pub const fn new(alpha: f64, threshold_k: f64) -> Self {
444        assert!(
445            alpha > 0.0 && alpha <= 1.0,
446            "EwmaDetector alpha must be in (0.0, 1.0]"
447        );
448        Self {
449            alpha,
450            ewma: 0.0,
451            ewma_var: 0.0,
452            threshold_k,
453            initialized: false,
454            count: 0,
455        }
456    }
457
458    /// Observe a new value and update statistics
459    pub fn observe(&mut self, value: f64) {
460        self.count += 1;
461
462        if !self.initialized {
463            self.ewma = value;
464            self.ewma_var = 0.0;
465            self.initialized = true;
466            return;
467        }
468
469        // Update EWMA
470        let deviation = value - self.ewma;
471        self.ewma += self.alpha * deviation;
472
473        // Update variance EWMA
474        self.ewma_var =
475            (1.0 - self.alpha) * (self.alpha * deviation).mul_add(deviation, self.ewma_var);
476    }
477
478    /// Check if a value is an anomaly (without updating)
479    #[must_use]
480    pub fn is_anomaly(&self, value: f64) -> bool {
481        if !self.initialized || self.count < 3 {
482            return false;
483        }
484
485        let std_dev = self.ewma_var.sqrt();
486        if std_dev < 1e-10 {
487            return (value - self.ewma).abs() > 1e-10;
488        }
489
490        let deviation = (value - self.ewma).abs();
491        deviation > self.threshold_k * std_dev
492    }
493
494    /// Get anomaly score (number of standard deviations)
495    ///
496    /// 分散推定が 0 のとき (定数 stream、または `alpha = 1.0` で平滑化しない場合)
497    /// は、`ewma` と一致する値に `0.0`、それ以外に `f64::INFINITY` を返す
498    /// ⚠️ **これは `alpha = 1.0` の境界固有の挙動ではない**: `alpha = 0.3` でも
499    /// 同じ値を 50 回入れれば `std_dev` は 0 になり、以後 `ewma` 以外は `inf` に
500    /// なる (実測) 「散らばりが 0 の系列に対する逸脱は無限に異常」が契約で、
501    /// `tests/panic_contract.rs` が値で固定する
502    #[must_use]
503    pub fn anomaly_score(&self, value: f64) -> f64 {
504        if !self.initialized {
505            return 0.0;
506        }
507
508        let std_dev = self.ewma_var.sqrt();
509        if std_dev < 1e-10 {
510            return if (value - self.ewma).abs() > 1e-10 {
511                f64::INFINITY
512            } else {
513                0.0
514            };
515        }
516
517        let inv_std = 1.0 / std_dev;
518        (value - self.ewma).abs() * inv_std
519    }
520
521    /// Get current EWMA
522    #[inline]
523    #[must_use]
524    pub const fn ewma(&self) -> f64 {
525        self.ewma
526    }
527
528    /// Get current standard deviation estimate
529    #[inline]
530    #[must_use]
531    pub fn std_dev(&self) -> f64 {
532        self.ewma_var.sqrt()
533    }
534
535    /// Get smoothing factor
536    #[inline]
537    #[must_use]
538    pub const fn alpha(&self) -> f64 {
539        self.alpha
540    }
541
542    /// Set smoothing factor
543    ///
544    /// # Panics
545    ///
546    /// [`Self::new`] と同じ条件 (`alpha` が `(0.0, 1.0]` の外) setter が丸めたままだと
547    /// 構築時の assert を後から回避できるので、同じ検査を置く
548    #[inline]
549    pub const fn set_alpha(&mut self, alpha: f64) {
550        assert!(
551            alpha > 0.0 && alpha <= 1.0,
552            "EwmaDetector alpha must be in (0.0, 1.0]"
553        );
554        self.alpha = alpha;
555    }
556
557    /// Get threshold multiplier
558    #[inline]
559    #[must_use]
560    pub const fn threshold_k(&self) -> f64 {
561        self.threshold_k
562    }
563
564    /// Set threshold multiplier
565    #[inline]
566    pub const fn set_threshold_k(&mut self, k: f64) {
567        self.threshold_k = k;
568    }
569
570    /// Get count of observations
571    #[inline]
572    #[must_use]
573    pub const fn count(&self) -> u64 {
574        self.count
575    }
576
577    /// Reset the detector
578    pub const fn reset(&mut self) {
579        self.ewma = 0.0;
580        self.ewma_var = 0.0;
581        self.initialized = false;
582        self.count = 0;
583    }
584}
585
586// ============================================================================
587// Z-Score Detector (Simple threshold-based)
588// ============================================================================
589
590/// Simple Z-score based anomaly detector
591///
592/// Maintains running mean and variance, flags values that exceed
593/// a threshold number of standard deviations.
594#[derive(Clone, Debug)]
595pub struct ZScoreDetector {
596    /// Running mean
597    mean: f64,
598    /// Running variance (using Welford's algorithm)
599    m2: f64,
600    /// Count of observations
601    count: u64,
602    /// Threshold (number of standard deviations)
603    threshold_k: f64,
604}
605
606impl ZScoreDetector {
607    /// Create a new Z-score detector
608    #[must_use]
609    pub const fn new(threshold_k: f64) -> Self {
610        Self {
611            mean: 0.0,
612            m2: 0.0,
613            count: 0,
614            threshold_k,
615        }
616    }
617
618    /// Observe a new value (updates running statistics)
619    pub fn observe(&mut self, value: f64) {
620        self.count += 1;
621        let inv_count = 1.0 / u64_f64(self.count);
622        let delta = value - self.mean;
623        self.mean += delta * inv_count;
624        let delta2 = value - self.mean;
625        self.m2 += delta * delta2;
626    }
627
628    /// Get the variance
629    #[inline]
630    #[must_use]
631    pub fn variance(&self) -> f64 {
632        if self.count < 2 {
633            0.0
634        } else {
635            self.m2 / u64_f64(self.count - 1)
636        }
637    }
638
639    /// Get the standard deviation
640    #[inline]
641    #[must_use]
642    pub fn std_dev(&self) -> f64 {
643        self.variance().sqrt()
644    }
645
646    /// Check if a value is an anomaly
647    #[must_use]
648    pub fn is_anomaly(&self, value: f64) -> bool {
649        if self.count < 3 {
650            return false;
651        }
652
653        let std_dev = self.std_dev();
654        if std_dev < 1e-10 {
655            return (value - self.mean).abs() > 1e-10;
656        }
657
658        let inv_std = 1.0 / std_dev;
659        let z_score = (value - self.mean).abs() * inv_std;
660        z_score > self.threshold_k
661    }
662
663    /// Get the Z-score for a value
664    #[must_use]
665    pub fn z_score(&self, value: f64) -> f64 {
666        let std_dev = self.std_dev();
667        if std_dev < 1e-10 {
668            return if (value - self.mean).abs() > 1e-10 {
669                f64::INFINITY
670            } else {
671                0.0
672            };
673        }
674        let inv_std = 1.0 / std_dev;
675        (value - self.mean) * inv_std
676    }
677
678    /// Get current mean
679    #[inline]
680    #[must_use]
681    pub const fn mean(&self) -> f64 {
682        self.mean
683    }
684
685    /// Get count
686    #[inline]
687    #[must_use]
688    pub const fn count(&self) -> u64 {
689        self.count
690    }
691
692    /// Reset the detector
693    pub const fn reset(&mut self) {
694        self.mean = 0.0;
695        self.m2 = 0.0;
696        self.count = 0;
697    }
698}
699
700// ============================================================================
701// Anomaly Event
702// ============================================================================
703
704/// Represents a detected anomaly
705#[derive(Clone, Debug)]
706pub struct AnomalyEvent {
707    /// The anomalous value
708    pub value: f64,
709    /// Anomaly score (e.g., number of MAD/std devs)
710    pub score: f64,
711    /// Expected value (median or mean)
712    pub expected: f64,
713    /// Timestamp (if applicable)
714    pub timestamp: u64,
715    /// Metric identifier
716    pub metric_id: u64,
717}
718
719/// Callback for anomaly detection
720pub trait AnomalyCallback {
721    /// Called when an anomaly is detected
722    fn on_anomaly(&mut self, event: AnomalyEvent);
723}
724
725// ============================================================================
726// Composite Anomaly Detector
727// ============================================================================
728
729/// Composite detector that combines multiple detection methods
730///
731/// An observation is flagged as anomalous if ANY detector flags it.
732#[derive(Clone, Debug)]
733pub struct CompositeDetector {
734    /// MAD-based detector
735    pub mad: MadDetector,
736    /// EWMA-based detector
737    pub ewma: EwmaDetector,
738    /// Z-score detector
739    pub zscore: ZScoreDetector,
740    /// Whether to require all detectors to agree
741    pub require_consensus: bool,
742}
743
744impl CompositeDetector {
745    /// Create a new composite detector with default settings
746    #[must_use]
747    pub const fn new() -> Self {
748        Self {
749            mad: MadDetector::new(3.0),
750            ewma: EwmaDetector::new(0.1, 3.0),
751            zscore: ZScoreDetector::new(3.0),
752            require_consensus: false,
753        }
754    }
755
756    /// Create with custom thresholds
757    ///
758    /// # Panics
759    ///
760    /// `ewma_alpha` が `(0.0, 1.0]` の外の場合 ([`EwmaDetector::new`] にそのまま渡す)
761    #[must_use]
762    pub const fn with_thresholds(mad_k: f64, ewma_alpha: f64, ewma_k: f64, zscore_k: f64) -> Self {
763        Self {
764            mad: MadDetector::new(mad_k),
765            ewma: EwmaDetector::new(ewma_alpha, ewma_k),
766            zscore: ZScoreDetector::new(zscore_k),
767            require_consensus: false,
768        }
769    }
770
771    /// Observe a new value (updates all detectors)
772    pub fn observe(&mut self, value: f64) {
773        self.mad.observe(value);
774        self.ewma.observe(value);
775        self.zscore.observe(value);
776    }
777
778    /// Check if a value is an anomaly
779    pub fn is_anomaly(&mut self, value: f64) -> bool {
780        let mad_anomaly = self.mad.is_anomaly(value);
781        let ewma_anomaly = self.ewma.is_anomaly(value);
782        let zscore_anomaly = self.zscore.is_anomaly(value);
783
784        if self.require_consensus {
785            // All must agree
786            mad_anomaly && ewma_anomaly && zscore_anomaly
787        } else {
788            // Any detector flags it
789            mad_anomaly || ewma_anomaly || zscore_anomaly
790        }
791    }
792
793    /// Get combined anomaly score (max of all scores)
794    pub fn anomaly_score(&mut self, value: f64) -> f64 {
795        let mad_score = self.mad.anomaly_score(value);
796        let ewma_score = self.ewma.anomaly_score(value);
797        let zscore_score = self.zscore.z_score(value).abs();
798
799        mad_score.max(ewma_score).max(zscore_score)
800    }
801
802    /// Get count of observations
803    #[inline]
804    #[must_use]
805    pub const fn count(&self) -> u64 {
806        self.zscore.count()
807    }
808
809    /// Reset all detectors
810    pub const fn reset(&mut self) {
811        self.mad.clear();
812        self.ewma.reset();
813        self.zscore.reset();
814    }
815}
816
817impl Default for CompositeDetector {
818    fn default() -> Self {
819        Self::new()
820    }
821}
822
823// ============================================================================
824// Tests
825// ============================================================================
826
827#[cfg(test)]
828#[allow(clippy::float_cmp)]
829mod tests {
830    use super::*;
831    use crate::math::usize_f64;
832
833    #[test]
834    fn test_streaming_median() {
835        let mut sm = StreamingMedian::new();
836
837        for v in [5.0, 2.0, 8.0, 1.0, 9.0] {
838            sm.push(v);
839        }
840
841        let median = sm.median();
842        assert_eq!(median, 5.0); // Sorted: 1, 2, 5, 8, 9 -> median = 5
843    }
844
845    #[test]
846    fn test_streaming_median_even() {
847        let mut sm = StreamingMedian::new();
848
849        for v in [1.0, 2.0, 3.0, 4.0] {
850            sm.push(v);
851        }
852
853        let median = sm.median();
854        assert_eq!(median, 2.5); // Sorted: 1, 2, 3, 4 -> median = (2+3)/2 = 2.5
855    }
856
857    #[test]
858    fn test_mad_detector_normal() {
859        let mut detector = MadDetector::new(3.0);
860
861        // Add normal data centered around 10
862        for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9, 10.3, 9.7, 10.4] {
863            detector.observe(v);
864        }
865
866        // Normal values should not be anomalies
867        assert!(!detector.is_anomaly(10.0));
868        assert!(!detector.is_anomaly(10.5));
869        assert!(!detector.is_anomaly(9.5));
870
871        // Extreme values should be anomalies
872        assert!(detector.is_anomaly(100.0));
873        assert!(detector.is_anomaly(-50.0));
874    }
875
876    #[test]
877    fn test_ewma_detector() {
878        let mut detector = EwmaDetector::new(0.1, 3.0);
879
880        // Train on values with some variance
881        for i in 0..100 {
882            let value = 10.0 + f64::from(i % 3) - 1.0; // 9, 10, 11
883            detector.observe(value);
884        }
885
886        // Should be close to 10
887        assert!((detector.ewma() - 10.0).abs() < 1.0);
888
889        // Normal values (within trained range)
890        assert!(!detector.is_anomaly(10.0));
891        assert!(!detector.is_anomaly(11.0));
892
893        // Anomaly (far outside trained range)
894        assert!(detector.is_anomaly(50.0));
895    }
896
897    #[test]
898    fn test_zscore_detector() {
899        let mut detector = ZScoreDetector::new(3.0);
900
901        // Add normal data
902        for v in [10.0, 11.0, 9.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9, 10.3] {
903            detector.observe(v);
904        }
905
906        assert!((detector.mean() - 10.03).abs() < 0.01);
907
908        // Normal values
909        assert!(!detector.is_anomaly(10.0));
910
911        // Anomaly
912        assert!(detector.is_anomaly(100.0));
913    }
914
915    #[test]
916    fn test_composite_detector() {
917        let mut detector = CompositeDetector::new();
918
919        // Train on normal data
920        for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9, 10.3, 9.7, 10.4] {
921            detector.observe(v);
922        }
923
924        // Normal
925        assert!(!detector.is_anomaly(10.0));
926
927        // Anomaly
928        assert!(detector.is_anomaly(100.0));
929
930        let score = detector.anomaly_score(100.0);
931        assert!(score > 3.0);
932    }
933
934    #[test]
935    fn test_mad_detector_constant() {
936        let mut detector = MadDetector::new(3.0);
937
938        // All same values
939        for _ in 0..10 {
940            detector.observe(5.0);
941        }
942
943        // Same value is not anomaly
944        assert!(!detector.is_anomaly(5.0));
945
946        // Different value is anomaly (MAD = 0)
947        assert!(detector.is_anomaly(5.1));
948    }
949
950    #[test]
951    fn test_streaming_median_default() {
952        let sm = StreamingMedian::default();
953        assert_eq!(sm.count(), 0);
954        assert!(!sm.is_full());
955    }
956
957    #[test]
958    fn test_streaming_median_empty() {
959        let mut sm = StreamingMedian::new();
960        assert_eq!(sm.median(), 0.0);
961    }
962
963    #[test]
964    fn test_streaming_median_clear() {
965        let mut sm = StreamingMedian::new();
966        sm.push(1.0);
967        sm.push(2.0);
968        assert_eq!(sm.count(), 2);
969        sm.clear();
970        assert_eq!(sm.count(), 0);
971    }
972
973    #[test]
974    fn test_streaming_median_full() {
975        let mut sm = StreamingMedian::new();
976        for i in 0..DEFAULT_WINDOW {
977            sm.push(usize_f64(i));
978        }
979        assert!(sm.is_full());
980        assert_eq!(sm.count(), DEFAULT_WINDOW);
981    }
982
983    #[test]
984    fn test_streaming_median_wrap() {
985        let mut sm = StreamingMedian::new();
986        // Fill and then wrap
987        for i in 0..(DEFAULT_WINDOW + 10) {
988            sm.push(usize_f64(i));
989        }
990        assert_eq!(sm.count(), DEFAULT_WINDOW);
991        let median = sm.median();
992        assert!(median > 0.0);
993    }
994
995    #[test]
996    fn test_mad_detector_insufficient_data() {
997        let mut detector = MadDetector::new(3.0);
998        detector.observe(10.0);
999        detector.observe(20.0);
1000        // Less than 3 observations: never anomaly
1001        assert!(!detector.is_anomaly(1000.0));
1002    }
1003
1004    #[test]
1005    fn test_mad_detector_count() {
1006        let mut detector = MadDetector::new(3.0);
1007        assert_eq!(detector.count(), 0);
1008        detector.observe(1.0);
1009        assert_eq!(detector.count(), 1);
1010    }
1011
1012    #[test]
1013    fn test_mad_detector_threshold_k() {
1014        let detector = MadDetector::new(5.0);
1015        assert!((detector.threshold_k() - 5.0).abs() < f64::EPSILON);
1016    }
1017
1018    #[test]
1019    fn test_mad_detector_set_threshold() {
1020        let mut detector = MadDetector::new(3.0);
1021        detector.set_threshold_k(5.0);
1022        assert!((detector.threshold_k() - 5.0).abs() < f64::EPSILON);
1023    }
1024
1025    #[test]
1026    fn test_mad_detector_clear() {
1027        let mut detector = MadDetector::new(3.0);
1028        for v in [10.0, 11.0, 9.0, 10.5] {
1029            detector.observe(v);
1030        }
1031        detector.clear();
1032        assert_eq!(detector.count(), 0);
1033    }
1034
1035    #[test]
1036    fn test_mad_detector_anomaly_score() {
1037        let mut detector = MadDetector::new(3.0);
1038        for v in [10.0, 10.5, 9.5, 10.2, 9.8] {
1039            detector.observe(v);
1040        }
1041        let score_normal = detector.anomaly_score(10.0);
1042        let score_extreme = detector.anomaly_score(100.0);
1043        assert!(score_extreme > score_normal);
1044    }
1045
1046    #[test]
1047    fn test_mad_detector_median_and_mad() {
1048        let mut detector = MadDetector::new(3.0);
1049        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
1050            detector.observe(v);
1051        }
1052        let median = detector.median();
1053        assert!((median - 3.0).abs() < 0.01);
1054        let mad = detector.mad();
1055        assert!(mad > 0.0);
1056    }
1057
1058    #[test]
1059    fn test_ewma_detector_not_initialized() {
1060        let detector = EwmaDetector::new(0.1, 3.0);
1061        assert!(!detector.is_anomaly(100.0));
1062        assert_eq!(detector.anomaly_score(100.0), 0.0);
1063    }
1064
1065    #[test]
1066    fn test_ewma_detector_count() {
1067        let mut detector = EwmaDetector::new(0.1, 3.0);
1068        assert_eq!(detector.count(), 0);
1069        detector.observe(10.0);
1070        assert_eq!(detector.count(), 1);
1071    }
1072
1073    #[test]
1074    fn test_ewma_detector_alpha() {
1075        let detector = EwmaDetector::new(0.2, 3.0);
1076        assert!((detector.alpha() - 0.2).abs() < f64::EPSILON);
1077    }
1078
1079    #[test]
1080    fn test_ewma_detector_set_alpha() {
1081        let mut detector = EwmaDetector::new(0.1, 3.0);
1082        detector.set_alpha(0.5);
1083        assert!((detector.alpha() - 0.5).abs() < f64::EPSILON);
1084    }
1085
1086    #[test]
1087    fn test_ewma_detector_set_threshold() {
1088        let mut detector = EwmaDetector::new(0.1, 3.0);
1089        detector.set_threshold_k(5.0);
1090        assert!((detector.threshold_k() - 5.0).abs() < f64::EPSILON);
1091    }
1092
1093    #[test]
1094    fn test_ewma_detector_std_dev() {
1095        let mut detector = EwmaDetector::new(0.1, 3.0);
1096        for v in [10.0, 20.0, 30.0, 40.0, 50.0] {
1097            detector.observe(v);
1098        }
1099        assert!(detector.std_dev() > 0.0);
1100    }
1101
1102    #[test]
1103    fn test_ewma_detector_reset() {
1104        let mut detector = EwmaDetector::new(0.1, 3.0);
1105        detector.observe(10.0);
1106        detector.observe(20.0);
1107        detector.reset();
1108        assert_eq!(detector.count(), 0);
1109    }
1110
1111    #[test]
1112    fn test_zscore_detector_insufficient_data() {
1113        let mut detector = ZScoreDetector::new(3.0);
1114        detector.observe(10.0);
1115        assert!(!detector.is_anomaly(1000.0));
1116    }
1117
1118    #[test]
1119    fn test_zscore_detector_variance() {
1120        let mut detector = ZScoreDetector::new(3.0);
1121        for v in [10.0, 20.0, 30.0] {
1122            detector.observe(v);
1123        }
1124        assert!(detector.variance() > 0.0);
1125        assert!(detector.std_dev() > 0.0);
1126    }
1127
1128    #[test]
1129    fn test_zscore_detector_z_score() {
1130        let mut detector = ZScoreDetector::new(3.0);
1131        for v in [10.0, 10.0, 10.0, 10.0, 10.0] {
1132            detector.observe(v);
1133        }
1134        // All same values, z_score for 10.0 should be 0
1135        let z = detector.z_score(10.0);
1136        assert!(z.abs() < f64::EPSILON || z == 0.0);
1137    }
1138
1139    #[test]
1140    fn test_zscore_detector_count() {
1141        let mut detector = ZScoreDetector::new(3.0);
1142        detector.observe(1.0);
1143        detector.observe(2.0);
1144        assert_eq!(detector.count(), 2);
1145    }
1146
1147    #[test]
1148    fn test_zscore_detector_reset() {
1149        let mut detector = ZScoreDetector::new(3.0);
1150        detector.observe(1.0);
1151        detector.observe(2.0);
1152        detector.reset();
1153        assert_eq!(detector.count(), 0);
1154        assert_eq!(detector.mean(), 0.0);
1155    }
1156
1157    #[test]
1158    fn test_composite_detector_default() {
1159        let detector = CompositeDetector::default();
1160        assert_eq!(detector.count(), 0);
1161    }
1162
1163    #[test]
1164    fn test_composite_detector_with_thresholds() {
1165        let detector = CompositeDetector::with_thresholds(2.0, 0.2, 2.5, 2.0);
1166        assert!((detector.mad.threshold_k() - 2.0).abs() < f64::EPSILON);
1167    }
1168
1169    #[test]
1170    fn test_composite_detector_consensus() {
1171        let mut detector = CompositeDetector::new();
1172        detector.require_consensus = true;
1173        for v in [10.0, 10.5, 9.5, 10.2, 9.8, 10.1, 9.9] {
1174            detector.observe(v);
1175        }
1176        // With consensus, borderline values less likely flagged
1177        assert!(!detector.is_anomaly(10.0));
1178    }
1179
1180    #[test]
1181    fn test_composite_detector_reset() {
1182        let mut detector = CompositeDetector::new();
1183        detector.observe(10.0);
1184        detector.observe(20.0);
1185        detector.reset();
1186        assert_eq!(detector.count(), 0);
1187    }
1188
1189    #[test]
1190    fn test_anomaly_event_clone() {
1191        let event = AnomalyEvent {
1192            value: 100.0,
1193            score: 5.0,
1194            expected: 10.0,
1195            timestamp: 12345,
1196            metric_id: 1,
1197        };
1198        let cloned = event;
1199        assert_eq!(cloned.value, 100.0);
1200        assert_eq!(cloned.metric_id, 1);
1201    }
1202
1203    // `format!` / `Vec` / the entropy-seeded constructors are `std`-only,
1204    // so this case cannot run on the `no_std` build
1205    #[cfg(feature = "std")]
1206    #[test]
1207    fn test_anomaly_event_debug() {
1208        let event = AnomalyEvent {
1209            value: 100.0,
1210            score: 5.0,
1211            expected: 10.0,
1212            timestamp: 0,
1213            metric_id: 0,
1214        };
1215        let s = format!("{event:?}");
1216        assert!(s.contains("value"));
1217    }
1218}