Skip to main content

optirs_core/streaming/adaptive_streaming/
drift_tests.rs

1// Real statistical drift tests, distribution comparators and model-based
2// detectors for the adaptive-streaming drift detector.
3//
4// Every detector here implements its published definition, produces its own
5// distinct test statistic, and derives its p-value from a real null
6// distribution (standard normal, Kolmogorov, or a Hoeffding/Chernoff bound
7// where the exact null has no closed form). Nothing in this module fabricates
8// a significance value or reuses another detector's statistic.
9//
10// References
11// - ADWIN: Bifet & Gavaldà, "Learning from Time-Changing Data with Adaptive
12//   Windowing", SDM 2007.
13// - DDM: Gama, Medas, Castillo & Rodrigues, "Learning with Drift Detection",
14//   SBIA 2004.
15// - EDDM: Baena-García et al., "Early Drift Detection Method", ECML/PKDD
16//   IWKDDS 2006.
17// - Page-Hinkley: Page, "Continuous Inspection Schemes", Biometrika 1954.
18// - CUSUM: Page, ibid.; Montgomery, "Introduction to Statistical Quality
19//   Control" for the k/h design rules.
20// - Two-sample Kolmogorov-Smirnov and Mann-Whitney U: standard nonparametric
21//   two-sample tests.
22
23use super::drift_detection::{
24    DistributionComparator, DistributionComparison, DriftTestResult, ModelBasedDetector,
25    ModelDriftResult, StatisticalTest,
26};
27use super::optimizer::StreamingDataPoint;
28use super::statistics as stats;
29
30use scirs2_core::numeric::Float;
31use std::collections::{HashMap, VecDeque};
32
33/// Converts a generic float into `f64`, reporting an honest error instead of
34/// silently substituting a default when the element type cannot represent it.
35fn to_f64<A: Float>(value: A) -> Result<f64, String> {
36    value
37        .to_f64()
38        .ok_or_else(|| "value cannot be represented as f64".to_string())
39}
40
41/// Converts an `f64` into the generic element type.
42fn from_f64<A: Float>(value: f64) -> Result<A, String> {
43    A::from(value).ok_or_else(|| format!("{value} cannot be represented in the element type"))
44}
45
46/// Collects a sample as `f64`, skipping non-finite observations.
47fn finite_f64<A: Float>(values: &[A]) -> Vec<f64> {
48    values
49        .iter()
50        .filter_map(|v| v.to_f64())
51        .filter(|v| v.is_finite())
52        .collect()
53}
54
55/// Maximum number of observations any single detector retains.
56const MAX_DETECTOR_WINDOW: usize = 4096;
57
58/// Number of histogram buckets used by the distribution comparators.
59const HISTOGRAM_BINS: usize = 16;
60
61/// Additive smoothing applied to histogram counts before a log-ratio is taken.
62const HISTOGRAM_SMOOTHING: f64 = 0.5;
63
64// ---------------------------------------------------------------------------
65// ADWIN
66// ---------------------------------------------------------------------------
67
68/// ADWIN (ADaptive WINdowing).
69///
70/// Maintains a single window of recent observations and, on every update,
71/// searches **every** valid split point for a pair of sub-windows whose means
72/// differ by more than the Hoeffding cut
73/// `eps_cut = R * sqrt(ln(4/delta) / (2m))`, where `m` is the harmonic
74/// combination `1/(1/n0 + 1/n1)` of the sub-window sizes and `R` is the
75/// observed range of the window (the practical adaptation used for unbounded
76/// metrics — the textbook bound assumes observations in `[0, 1]`). When a cut
77/// is found the older sub-window is dropped, which is ADWIN's defining
78/// behaviour: the window shrinks to the most recent stationary segment.
79///
80/// ADWIN is a single-stream detector, so it consumes only the newly arrived
81/// observations; the caller's reference sample is not used (that is what makes
82/// it genuinely different from the two-sample tests in this module).
83pub struct AdwinTest<A: Float + Send + Sync> {
84    /// Confidence parameter (the `delta` of the Hoeffding bound).
85    delta: f64,
86    /// Significance level used as a secondary gate.
87    significance_level: f64,
88    /// Observation window.
89    window: VecDeque<A>,
90    /// Smallest sub-window size considered on either side of a split.
91    min_sub_window: usize,
92}
93
94impl<A: Float + Send + Sync> AdwinTest<A> {
95    /// Creates an ADWIN detector. `sensitivity` is interpreted as ADWIN's
96    /// `delta` confidence parameter (smaller = more conservative).
97    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
98        if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
99            return Err(format!(
100                "ADWIN delta must lie strictly in (0, 1), got {sensitivity}"
101            ));
102        }
103        Ok(Self {
104            delta: sensitivity,
105            significance_level,
106            window: VecDeque::with_capacity(256),
107            min_sub_window: 5,
108        })
109    }
110}
111
112impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
113    for AdwinTest<A>
114{
115    fn test_for_drift(
116        &mut self,
117        _reference: &[A],
118        current: &[A],
119    ) -> Result<DriftTestResult<A>, String> {
120        if current.is_empty() {
121            return Err("ADWIN: empty observation batch".to_string());
122        }
123
124        for &value in current {
125            if self.window.len() >= MAX_DETECTOR_WINDOW {
126                self.window.pop_front();
127            }
128            self.window.push_back(value);
129        }
130
131        let observations: Vec<f64> = finite_f64(self.window.make_contiguous());
132        let n = observations.len();
133        if n < 2 * self.min_sub_window {
134            return insignificant_result(0.0, HashMap::new());
135        }
136
137        let (min, max) = stats::finite_range(&observations)
138            .ok_or_else(|| "ADWIN: window contains no finite observations".to_string())?;
139        // A zero-range window cannot exhibit a mean shift at all.
140        let range = (max - min).max(f64::MIN_POSITIVE);
141
142        // Prefix sums make the all-splits scan linear rather than quadratic.
143        let mut prefix = Vec::with_capacity(n + 1);
144        prefix.push(0.0_f64);
145        for &value in &observations {
146            let last = prefix[prefix.len() - 1];
147            prefix.push(last + value);
148        }
149        let total = prefix[n];
150
151        let ln_term = (4.0 / self.delta).ln();
152        let mut best_excess = f64::NEG_INFINITY;
153        let mut best_diff = 0.0_f64;
154        let mut best_split = 0usize;
155        let mut best_eps = 0.0_f64;
156        let mut best_m = 0.0_f64;
157
158        let first_split = self.min_sub_window;
159        let last_split = n - self.min_sub_window;
160        for (offset, &prefix_sum) in prefix[first_split..=last_split].iter().enumerate() {
161            let split = first_split + offset;
162            let n0 = split as f64;
163            let n1 = (n - split) as f64;
164            let mean0 = prefix_sum / n0;
165            let mean1 = (total - prefix_sum) / n1;
166            let diff = (mean0 - mean1).abs();
167
168            let harmonic_m = 1.0 / (1.0 / n0 + 1.0 / n1);
169            let eps_cut = range * (ln_term / (2.0 * harmonic_m)).sqrt();
170            let excess = diff - eps_cut;
171
172            if excess > best_excess {
173                best_excess = excess;
174                best_diff = diff;
175                best_split = split;
176                best_eps = eps_cut;
177                best_m = harmonic_m;
178            }
179        }
180
181        // Invert the Hoeffding bound at the most violating split to obtain a
182        // genuine (conservative) p-value: the bound states that under the
183        // no-change hypothesis, P(|mean0 - mean1| >= d) <= 4 exp(-2 m (d/R)^2).
184        let normalised = best_diff / range;
185        let p_value = (4.0 * (-2.0 * best_m * normalised * normalised).exp()).clamp(0.0, 1.0);
186
187        let cut_found = best_excess > 0.0;
188        if cut_found {
189            // Real ADWIN behaviour: forget the stale sub-window.
190            for _ in 0..best_split {
191                self.window.pop_front();
192            }
193        }
194
195        let mut metadata = HashMap::new();
196        metadata.insert("split_point".to_string(), from_f64::<A>(best_split as f64)?);
197        metadata.insert("window_size".to_string(), from_f64::<A>(n as f64)?);
198        metadata.insert("epsilon_cut".to_string(), from_f64::<A>(best_eps)?);
199        metadata.insert("observed_range".to_string(), from_f64::<A>(range)?);
200
201        Ok(DriftTestResult {
202            drift_detected: cut_found || p_value < self.significance_level,
203            p_value: from_f64(p_value)?,
204            test_statistic: from_f64(best_diff)?,
205            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
206            metadata,
207        })
208    }
209
210    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
211        // Positive feedback (detections were useful) relaxes delta so the
212        // detector stays responsive; negative feedback (false positives)
213        // tightens it. Real state change, bounded to a sane range.
214        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
215        let scale = (1.0 + 0.1 * feedback).clamp(0.5, 2.0);
216        self.delta = (self.delta * scale).clamp(1e-8, 0.5);
217        Ok(())
218    }
219
220    fn reset(&mut self) {
221        self.window.clear();
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Shared error-stream binarisation for DDM / EDDM
227// ---------------------------------------------------------------------------
228
229/// Turns a real-valued observation stream into the Bernoulli "error" stream
230/// that DDM and EDDM are defined over.
231///
232/// Both methods were published for classifiers, where the input is a sequence
233/// of 0/1 misclassification indicators. For a metric stream the standard
234/// adaptation is to threshold against the reference sample: an observation
235/// counts as an "error" when it falls further than one reference standard
236/// deviation from the reference mean. Under a stationary stream this yields a
237/// stable error probability (`~0.317` for Gaussian data); a shift of the mean
238/// **in either direction**, or an increase in spread, raises it — which is
239/// exactly the signal DDM and EDDM are built to detect.
240#[derive(Debug, Clone, Copy)]
241struct ReferenceBaseline {
242    mean: f64,
243    std_dev: f64,
244}
245
246impl ReferenceBaseline {
247    fn from_sample<A: Float>(reference: &[A]) -> Result<Self, String> {
248        let sample = finite_f64(reference);
249        if sample.len() < 2 {
250            return Err(
251                "error-stream baseline requires at least two reference observations".to_string(),
252            );
253        }
254        let mean = stats::mean(&sample).ok_or_else(|| "reference mean is undefined".to_string())?;
255        let std_dev = stats::sample_std_dev(&sample)
256            .ok_or_else(|| "reference standard deviation is undefined".to_string())?;
257        Ok(Self {
258            mean,
259            std_dev: std_dev.max(f64::MIN_POSITIVE),
260        })
261    }
262
263    fn is_error(&self, value: f64) -> bool {
264        (value - self.mean).abs() > self.std_dev
265    }
266}
267
268// ---------------------------------------------------------------------------
269// DDM
270// ---------------------------------------------------------------------------
271
272/// DDM (Drift Detection Method), Gama et al. 2004.
273///
274/// Tracks the running error probability `p_i` and its binomial standard
275/// deviation `s_i = sqrt(p_i (1 - p_i) / i)`, remembers the minimum of
276/// `p_i + s_i` seen so far, and signals a warning at
277/// `p_i + s_i >= p_min + 2 s_min` and drift at `p_i + s_i >= p_min + 3 s_min`.
278/// The reported statistic is the number of `s_min` units the current error
279/// level sits above `p_min`, and the p-value is the one-sided normal tail of
280/// that z score.
281pub struct DdmTest<A: Float + Send + Sync> {
282    significance_level: f64,
283    warning_level: f64,
284    drift_level: f64,
285    min_instances: usize,
286    instances: usize,
287    errors: usize,
288    p_min: f64,
289    s_min: f64,
290    warning_active: bool,
291    _marker: std::marker::PhantomData<A>,
292}
293
294impl<A: Float + Send + Sync> DdmTest<A> {
295    /// Creates a DDM detector. `sensitivity` scales the published 2-sigma /
296    /// 3-sigma warning and drift levels, so a smaller value makes the detector
297    /// fire earlier.
298    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
299        if !(sensitivity.is_finite() && sensitivity > 0.0) {
300            return Err(format!(
301                "DDM sensitivity must be positive, got {sensitivity}"
302            ));
303        }
304        // The published levels are 2 and 3 standard deviations. `sensitivity`
305        // (a value in (0, 1]) shrinks them proportionally so the configured
306        // sensitivity has a real, monotone effect on when DDM fires.
307        let scale = (0.5 + sensitivity).clamp(0.5, 1.5);
308        Ok(Self {
309            significance_level,
310            warning_level: 2.0 * scale,
311            drift_level: 3.0 * scale,
312            min_instances: 30,
313            instances: 0,
314            errors: 0,
315            p_min: f64::INFINITY,
316            s_min: f64::INFINITY,
317            warning_active: false,
318            _marker: std::marker::PhantomData,
319        })
320    }
321
322    /// Whether DDM is currently in its warning zone (between the 2-sigma and
323    /// 3-sigma levels).
324    pub fn is_warning(&self) -> bool {
325        self.warning_active
326    }
327}
328
329impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for DdmTest<A> {
330    fn test_for_drift(
331        &mut self,
332        reference: &[A],
333        current: &[A],
334    ) -> Result<DriftTestResult<A>, String> {
335        if current.is_empty() {
336            return Err("DDM: empty observation batch".to_string());
337        }
338        let baseline = ReferenceBaseline::from_sample(reference)?;
339
340        for value in finite_f64(current) {
341            self.instances += 1;
342            if baseline.is_error(value) {
343                self.errors += 1;
344            }
345
346            let n = self.instances as f64;
347            let p = self.errors as f64 / n;
348            let s = (p * (1.0 - p) / n).sqrt();
349
350            if p + s < self.p_min + self.s_min {
351                self.p_min = p;
352                self.s_min = s;
353            }
354        }
355
356        let n = self.instances as f64;
357        let p = self.errors as f64 / n;
358        let s = (p * (1.0 - p) / n).sqrt();
359
360        // Guard the very first updates, where s_min can legitimately be zero.
361        let s_min = if self.s_min.is_finite() && self.s_min > 0.0 {
362            self.s_min
363        } else {
364            (p * (1.0 - p) / n).sqrt().max(f64::MIN_POSITIVE)
365        };
366        let p_min = if self.p_min.is_finite() {
367            self.p_min
368        } else {
369            p
370        };
371
372        let z = ((p + s) - p_min) / s_min;
373        let p_value = stats::standard_normal_sf(z)?;
374
375        let enough_data = self.instances >= self.min_instances;
376        self.warning_active = enough_data && z >= self.warning_level && z < self.drift_level;
377        let drift_detected =
378            enough_data && (z >= self.drift_level || p_value < self.significance_level);
379
380        if drift_detected {
381            // Published DDM behaviour: restart the statistics after a drift so
382            // the new concept establishes its own p_min baseline.
383            self.instances = 0;
384            self.errors = 0;
385            self.p_min = f64::INFINITY;
386            self.s_min = f64::INFINITY;
387            self.warning_active = false;
388        }
389
390        let mut metadata = HashMap::new();
391        metadata.insert("error_rate".to_string(), from_f64::<A>(p)?);
392        metadata.insert("p_min".to_string(), from_f64::<A>(p_min)?);
393        metadata.insert("s_min".to_string(), from_f64::<A>(s_min)?);
394        metadata.insert(
395            "warning_level".to_string(),
396            from_f64::<A>(self.warning_level)?,
397        );
398
399        Ok(DriftTestResult {
400            drift_detected,
401            p_value: from_f64(p_value)?,
402            test_statistic: from_f64(z)?,
403            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
404            metadata,
405        })
406    }
407
408    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
409        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
410        // Negative feedback (too many false alarms) pushes the drift level up.
411        self.drift_level = (self.drift_level - 0.2 * feedback).clamp(1.5, 6.0);
412        self.warning_level = self.warning_level.min(self.drift_level - 0.25).max(1.0);
413        Ok(())
414    }
415
416    fn reset(&mut self) {
417        self.instances = 0;
418        self.errors = 0;
419        self.p_min = f64::INFINITY;
420        self.s_min = f64::INFINITY;
421        self.warning_active = false;
422    }
423}
424
425// ---------------------------------------------------------------------------
426// EDDM
427// ---------------------------------------------------------------------------
428
429/// EDDM (Early Drift Detection Method), Baena-García et al. 2006.
430///
431/// Where DDM watches the error *rate*, EDDM watches the **distance between
432/// consecutive errors**: as a concept degrades, errors bunch together and that
433/// distance falls. It tracks the running mean `p'` and standard deviation `s'`
434/// of the inter-error distance, remembers the maximum of `p' + 2 s'` ever
435/// observed, and reports the ratio `(p' + 2 s') / (p'_max + 2 s'_max)`. The
436/// published thresholds are `0.95` for warning and `0.90` for drift.
437pub struct EddmTest<A: Float + Send + Sync> {
438    significance_level: f64,
439    warning_ratio: f64,
440    drift_ratio: f64,
441    min_errors: usize,
442    /// Number of observations since the previous error.
443    since_last_error: usize,
444    /// Welford accumulators over the inter-error distances.
445    error_count: usize,
446    mean_distance: f64,
447    m2_distance: f64,
448    max_criterion: f64,
449    warning_active: bool,
450    _marker: std::marker::PhantomData<A>,
451}
452
453impl<A: Float + Send + Sync> EddmTest<A> {
454    /// Creates an EDDM detector. `sensitivity` nudges the published `0.95` /
455    /// `0.90` ratio thresholds.
456    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
457        if !(sensitivity.is_finite() && sensitivity > 0.0) {
458            return Err(format!(
459                "EDDM sensitivity must be positive, got {sensitivity}"
460            ));
461        }
462        // A larger sensitivity relaxes the ratios (fires sooner); the offsets
463        // stay small so the published thresholds remain recognisable.
464        let shift = (sensitivity * 0.1).clamp(0.0, 0.05);
465        Ok(Self {
466            significance_level,
467            warning_ratio: 0.95 + shift,
468            drift_ratio: 0.90 + shift,
469            min_errors: 30,
470            since_last_error: 0,
471            error_count: 0,
472            mean_distance: 0.0,
473            m2_distance: 0.0,
474            max_criterion: 0.0,
475            warning_active: false,
476            _marker: std::marker::PhantomData,
477        })
478    }
479
480    /// Whether EDDM is currently in its warning zone.
481    pub fn is_warning(&self) -> bool {
482        self.warning_active
483    }
484
485    fn criterion(&self) -> f64 {
486        let variance = if self.error_count > 1 {
487            self.m2_distance / (self.error_count - 1) as f64
488        } else {
489            0.0
490        };
491        self.mean_distance + 2.0 * variance.max(0.0).sqrt()
492    }
493}
494
495impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for EddmTest<A> {
496    fn test_for_drift(
497        &mut self,
498        reference: &[A],
499        current: &[A],
500    ) -> Result<DriftTestResult<A>, String> {
501        if current.is_empty() {
502            return Err("EDDM: empty observation batch".to_string());
503        }
504        let baseline = ReferenceBaseline::from_sample(reference)?;
505
506        for value in finite_f64(current) {
507            self.since_last_error += 1;
508            if !baseline.is_error(value) {
509                continue;
510            }
511
512            // Welford update over the inter-error distance.
513            let distance = self.since_last_error as f64;
514            self.since_last_error = 0;
515            self.error_count += 1;
516            let delta = distance - self.mean_distance;
517            self.mean_distance += delta / self.error_count as f64;
518            self.m2_distance += delta * (distance - self.mean_distance);
519
520            let criterion = self.criterion();
521            if criterion > self.max_criterion {
522                self.max_criterion = criterion;
523            }
524        }
525
526        let criterion = self.criterion();
527        let ratio = if self.max_criterion > 0.0 {
528            criterion / self.max_criterion
529        } else {
530            1.0
531        };
532
533        // Real significance for "the mean inter-error distance has fallen
534        // below the best value seen": a one-sample z test on the mean, using
535        // the running standard error of the inter-error distance.
536        let variance = if self.error_count > 1 {
537            self.m2_distance / (self.error_count - 1) as f64
538        } else {
539            0.0
540        };
541        let standard_error = if self.error_count > 0 {
542            (variance / self.error_count as f64).sqrt()
543        } else {
544            0.0
545        };
546        let z = if standard_error > 0.0 {
547            (self.max_criterion - criterion) / standard_error
548        } else {
549            0.0
550        };
551        let p_value = stats::standard_normal_sf(z)?;
552
553        let enough_errors = self.error_count >= self.min_errors;
554        self.warning_active =
555            enough_errors && ratio < self.warning_ratio && ratio >= self.drift_ratio;
556        let drift_detected =
557            enough_errors && (ratio < self.drift_ratio || p_value < self.significance_level);
558
559        if drift_detected {
560            self.error_count = 0;
561            self.mean_distance = 0.0;
562            self.m2_distance = 0.0;
563            self.max_criterion = 0.0;
564            self.since_last_error = 0;
565            self.warning_active = false;
566        }
567
568        let mut metadata = HashMap::new();
569        metadata.insert("criterion_ratio".to_string(), from_f64::<A>(ratio)?);
570        metadata.insert(
571            "mean_error_distance".to_string(),
572            from_f64::<A>(self.mean_distance)?,
573        );
574        metadata.insert(
575            "max_criterion".to_string(),
576            from_f64::<A>(self.max_criterion)?,
577        );
578
579        Ok(DriftTestResult {
580            drift_detected,
581            // The statistic is the ratio itself, which is what EDDM thresholds.
582            test_statistic: from_f64(ratio)?,
583            p_value: from_f64(p_value)?,
584            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
585            metadata,
586        })
587    }
588
589    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
590        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
591        self.drift_ratio = (self.drift_ratio + 0.01 * feedback).clamp(0.70, 0.99);
592        self.warning_ratio = self.warning_ratio.max(self.drift_ratio + 0.005).min(0.999);
593        Ok(())
594    }
595
596    fn reset(&mut self) {
597        self.since_last_error = 0;
598        self.error_count = 0;
599        self.mean_distance = 0.0;
600        self.m2_distance = 0.0;
601        self.max_criterion = 0.0;
602        self.warning_active = false;
603    }
604}
605
606// ---------------------------------------------------------------------------
607// Page-Hinkley
608// ---------------------------------------------------------------------------
609
610/// Page-Hinkley test.
611///
612/// Accumulates the centred, slack-adjusted deviations of the stream from its
613/// own running mean and reports how far the cumulative sum has moved away from
614/// its running extremum:
615///
616/// ```text
617/// x̄_t  = running mean of all observations
618/// m⁺_t = Σ (x_t - x̄_t - δ)      M⁺_t = min m⁺      PH⁺ = m⁺_t - M⁺_t
619/// m⁻_t = Σ (x̄_t - x_t - δ)      M⁻_t = min m⁻      PH⁻ = m⁻_t - M⁻_t
620/// ```
621///
622/// Drift is signalled when `max(PH⁺, PH⁻) > λ`. Tracking both directions makes
623/// the detector symmetric, so a downward shift is caught as well as an upward
624/// one. The running mean is genuinely running: there is no hard-coded baseline.
625pub struct PageHinkleyTest<A: Float + Send + Sync> {
626    significance_level: f64,
627    /// Magnitude of change tolerated before the sum starts to accumulate.
628    delta: f64,
629    /// Detection threshold.
630    lambda: f64,
631    /// Welford accumulators for the running mean and variance.
632    count: usize,
633    mean: f64,
634    m2: f64,
635    /// Cumulative sums and their running minima, per direction.
636    sum_increase: f64,
637    min_increase: f64,
638    sum_decrease: f64,
639    min_decrease: f64,
640    /// Observations since the current run started (reset on detection).
641    run_length: usize,
642    _marker: std::marker::PhantomData<A>,
643}
644
645impl<A: Float + Send + Sync> PageHinkleyTest<A> {
646    /// Creates a Page-Hinkley detector. `sensitivity` becomes the slack `delta`
647    /// (expressed in units of the stream's own running standard deviation) and
648    /// also sets the detection threshold `lambda`.
649    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
650        if !(sensitivity.is_finite() && sensitivity > 0.0) {
651            return Err(format!(
652                "Page-Hinkley sensitivity must be positive, got {sensitivity}"
653            ));
654        }
655        Ok(Self {
656            significance_level,
657            delta: sensitivity,
658            // A threshold of 50 slack units is the common practical default;
659            // scaling it by 1/sensitivity keeps a smaller sensitivity value
660            // (a tighter slack) from firing on ordinary noise.
661            lambda: (5.0 / sensitivity).clamp(5.0, 500.0),
662            count: 0,
663            mean: 0.0,
664            m2: 0.0,
665            sum_increase: 0.0,
666            min_increase: 0.0,
667            sum_decrease: 0.0,
668            min_decrease: 0.0,
669            run_length: 0,
670            _marker: std::marker::PhantomData,
671        })
672    }
673}
674
675impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
676    for PageHinkleyTest<A>
677{
678    fn test_for_drift(
679        &mut self,
680        _reference: &[A],
681        current: &[A],
682    ) -> Result<DriftTestResult<A>, String> {
683        if current.is_empty() {
684            return Err("Page-Hinkley: empty observation batch".to_string());
685        }
686
687        for value in finite_f64(current) {
688            self.count += 1;
689            self.run_length += 1;
690            let delta_from_mean = value - self.mean;
691            self.mean += delta_from_mean / self.count as f64;
692            self.m2 += delta_from_mean * (value - self.mean);
693
694            let deviation = value - self.mean;
695            self.sum_increase += deviation - self.delta;
696            self.sum_decrease += -deviation - self.delta;
697            if self.sum_increase < self.min_increase {
698                self.min_increase = self.sum_increase;
699            }
700            if self.sum_decrease < self.min_decrease {
701                self.min_decrease = self.sum_decrease;
702            }
703        }
704
705        let ph_increase = self.sum_increase - self.min_increase;
706        let ph_decrease = self.sum_decrease - self.min_decrease;
707        let statistic = ph_increase.max(ph_decrease);
708
709        // The PH statistic is a sum of centred deviations over the current
710        // run, so under the no-change hypothesis it is approximately
711        // N(0, sigma^2 * run_length): z = PH / (sigma * sqrt(run_length)).
712        let variance = if self.count > 1 {
713            self.m2 / (self.count - 1) as f64
714        } else {
715            0.0
716        };
717        let sigma = variance.max(0.0).sqrt();
718        let z = if sigma > 0.0 && self.run_length > 0 {
719            statistic / (sigma * (self.run_length as f64).sqrt())
720        } else {
721            0.0
722        };
723        let p_value = stats::standard_normal_sf(z)?;
724
725        let drift_detected = statistic > self.lambda || p_value < self.significance_level;
726        if drift_detected {
727            self.sum_increase = 0.0;
728            self.min_increase = 0.0;
729            self.sum_decrease = 0.0;
730            self.min_decrease = 0.0;
731            self.run_length = 0;
732        }
733
734        let mut metadata = HashMap::new();
735        metadata.insert("ph_increase".to_string(), from_f64::<A>(ph_increase)?);
736        metadata.insert("ph_decrease".to_string(), from_f64::<A>(ph_decrease)?);
737        metadata.insert("running_mean".to_string(), from_f64::<A>(self.mean)?);
738        metadata.insert("lambda".to_string(), from_f64::<A>(self.lambda)?);
739
740        Ok(DriftTestResult {
741            drift_detected,
742            test_statistic: from_f64(statistic)?,
743            p_value: from_f64(p_value)?,
744            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
745            metadata,
746        })
747    }
748
749    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
750        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
751        self.lambda = (self.lambda * (1.0 - 0.1 * feedback)).clamp(1.0, 1000.0);
752        Ok(())
753    }
754
755    fn reset(&mut self) {
756        self.count = 0;
757        self.mean = 0.0;
758        self.m2 = 0.0;
759        self.sum_increase = 0.0;
760        self.min_increase = 0.0;
761        self.sum_decrease = 0.0;
762        self.min_decrease = 0.0;
763        self.run_length = 0;
764    }
765}
766
767// ---------------------------------------------------------------------------
768// CUSUM
769// ---------------------------------------------------------------------------
770
771/// Two-sided CUSUM control chart.
772///
773/// Uses the reference sample to fix the in-control mean and standard deviation,
774/// then accumulates
775/// `g⁺ = max(0, g⁺ + (x - μ - k))` and `g⁻ = max(0, g⁻ - (x - μ + k))`
776/// with the classic tabular design `k = 0.5σ` (half the shift to detect) and
777/// `h = 5σ` (which gives an in-control average run length of roughly 465 for
778/// that `k`). Unlike Page-Hinkley the reference level is *fixed* rather than
779/// running, which makes CUSUM sensitive to slow, sustained drift that a running
780/// mean would absorb.
781pub struct CusumTest<A: Float + Send + Sync> {
782    significance_level: f64,
783    /// Slack in units of the reference standard deviation.
784    k_sigma: f64,
785    /// Threshold in units of the reference standard deviation.
786    h_sigma: f64,
787    positive_sum: f64,
788    negative_sum: f64,
789    run_length: usize,
790    _marker: std::marker::PhantomData<A>,
791}
792
793impl<A: Float + Send + Sync> CusumTest<A> {
794    /// Creates a CUSUM detector. `sensitivity` scales the decision interval
795    /// `h`, so a larger sensitivity fires sooner.
796    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
797        if !(sensitivity.is_finite() && sensitivity > 0.0) {
798            return Err(format!(
799                "CUSUM sensitivity must be positive, got {sensitivity}"
800            ));
801        }
802        Ok(Self {
803            significance_level,
804            k_sigma: 0.5,
805            h_sigma: (5.0 * (1.0 - sensitivity).max(0.2)).clamp(1.0, 10.0),
806            positive_sum: 0.0,
807            negative_sum: 0.0,
808            run_length: 0,
809            _marker: std::marker::PhantomData,
810        })
811    }
812}
813
814impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
815    for CusumTest<A>
816{
817    fn test_for_drift(
818        &mut self,
819        reference: &[A],
820        current: &[A],
821    ) -> Result<DriftTestResult<A>, String> {
822        if current.is_empty() {
823            return Err("CUSUM: empty observation batch".to_string());
824        }
825        let baseline = ReferenceBaseline::from_sample(reference)?;
826        let sigma = baseline.std_dev;
827        let slack = self.k_sigma * sigma;
828
829        for value in finite_f64(current) {
830            self.run_length += 1;
831            let deviation = value - baseline.mean;
832            self.positive_sum = (self.positive_sum + deviation - slack).max(0.0);
833            self.negative_sum = (self.negative_sum - deviation - slack).max(0.0);
834        }
835
836        let statistic = self.positive_sum.max(self.negative_sum);
837        let threshold = self.h_sigma * sigma;
838
839        // As with Page-Hinkley, the accumulated sum over the current run is
840        // approximately normal under the in-control hypothesis.
841        let z = if sigma > 0.0 && self.run_length > 0 {
842            statistic / (sigma * (self.run_length as f64).sqrt())
843        } else {
844            0.0
845        };
846        let p_value = stats::standard_normal_sf(z)?;
847
848        let drift_detected = statistic > threshold || p_value < self.significance_level;
849        if drift_detected {
850            self.positive_sum = 0.0;
851            self.negative_sum = 0.0;
852            self.run_length = 0;
853        }
854
855        let mut metadata = HashMap::new();
856        metadata.insert(
857            "positive_sum".to_string(),
858            from_f64::<A>(self.positive_sum)?,
859        );
860        metadata.insert(
861            "negative_sum".to_string(),
862            from_f64::<A>(self.negative_sum)?,
863        );
864        metadata.insert("threshold".to_string(), from_f64::<A>(threshold)?);
865        metadata.insert("reference_mean".to_string(), from_f64::<A>(baseline.mean)?);
866
867        Ok(DriftTestResult {
868            drift_detected,
869            test_statistic: from_f64(statistic)?,
870            p_value: from_f64(p_value)?,
871            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
872            metadata,
873        })
874    }
875
876    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
877        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
878        self.h_sigma = (self.h_sigma * (1.0 - 0.1 * feedback)).clamp(1.0, 12.0);
879        Ok(())
880    }
881
882    fn reset(&mut self) {
883        self.positive_sum = 0.0;
884        self.negative_sum = 0.0;
885        self.run_length = 0;
886    }
887}
888
889// ---------------------------------------------------------------------------
890// Two-sample Kolmogorov-Smirnov
891// ---------------------------------------------------------------------------
892
893/// Two-sample Kolmogorov-Smirnov test.
894///
895/// The statistic is `D = max_x |F_ref(x) - F_cur(x)|` over the merged support
896/// of the two samples and the p-value comes from the asymptotic Kolmogorov
897/// distribution at `sqrt(n_eff) D`, `n_eff = n_ref n_cur / (n_ref + n_cur)`.
898/// This is a genuine *distribution* test: it reacts to changes in shape or
899/// spread that leave the mean untouched, which none of the mean-shift detectors
900/// above can see.
901pub struct KsTest<A: Float + Send + Sync> {
902    significance_level: f64,
903    _marker: std::marker::PhantomData<A>,
904}
905
906impl<A: Float + Send + Sync> KsTest<A> {
907    /// Creates a KS test whose rejection level is `sensitivity` (falling back
908    /// to the configured significance level when that is the tighter of the
909    /// two).
910    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
911        if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
912            return Err(format!(
913                "KS significance level must lie strictly in (0, 1), got {sensitivity}"
914            ));
915        }
916        Ok(Self {
917            significance_level: sensitivity.max(significance_level),
918            _marker: std::marker::PhantomData,
919        })
920    }
921}
922
923impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for KsTest<A> {
924    fn test_for_drift(
925        &mut self,
926        reference: &[A],
927        current: &[A],
928    ) -> Result<DriftTestResult<A>, String> {
929        let reference_sample = finite_f64(reference);
930        let current_sample = finite_f64(current);
931        if reference_sample.is_empty() || current_sample.is_empty() {
932            return Err("KS test: both samples must be non-empty".to_string());
933        }
934
935        let d = stats::ks_statistic(&reference_sample, &current_sample)
936            .ok_or_else(|| "KS test: empirical CDF is undefined".to_string())?;
937        let p_value = stats::ks_two_sample_p(d, reference_sample.len(), current_sample.len());
938
939        let mut metadata = HashMap::new();
940        metadata.insert(
941            "reference_size".to_string(),
942            from_f64::<A>(reference_sample.len() as f64)?,
943        );
944        metadata.insert(
945            "current_size".to_string(),
946            from_f64::<A>(current_sample.len() as f64)?,
947        );
948
949        Ok(DriftTestResult {
950            drift_detected: p_value < self.significance_level,
951            test_statistic: from_f64(d)?,
952            p_value: from_f64(p_value)?,
953            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
954            metadata,
955        })
956    }
957
958    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
959        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
960        self.significance_level =
961            (self.significance_level * (1.0 + 0.1 * feedback)).clamp(1e-6, 0.5);
962        Ok(())
963    }
964
965    fn reset(&mut self) {
966        // Stateless: each call is a fresh two-sample comparison.
967    }
968}
969
970// ---------------------------------------------------------------------------
971// Mann-Whitney U
972// ---------------------------------------------------------------------------
973
974/// Two-sample Mann-Whitney U test (Wilcoxon rank-sum) with tie correction.
975///
976/// Ranks the pooled sample, forms `U = R_1 - n_1(n_1 + 1)/2`, and uses the
977/// tie-corrected normal approximation
978/// `sigma^2 = n_1 n_2 / 12 * ((N + 1) - Σ(t^3 - t)/(N(N-1)))`
979/// with a continuity correction. This is a *location-shift* test on ranks, so
980/// it is robust to the heavy tails that would inflate a mean-difference
981/// statistic.
982pub struct MannWhitneyUTest<A: Float + Send + Sync> {
983    significance_level: f64,
984    _marker: std::marker::PhantomData<A>,
985}
986
987impl<A: Float + Send + Sync> MannWhitneyUTest<A> {
988    /// Creates a Mann-Whitney U test at the given rejection level.
989    pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
990        if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
991            return Err(format!(
992                "Mann-Whitney significance level must lie strictly in (0, 1), got {sensitivity}"
993            ));
994        }
995        Ok(Self {
996            significance_level: sensitivity.max(significance_level),
997            _marker: std::marker::PhantomData,
998        })
999    }
1000}
1001
1002/// Assigns mid-ranks to a pooled sample, returning the rank sum of the first
1003/// group and the tie-correction term `Σ (t^3 - t)`.
1004fn rank_sum_with_ties(group_a: &[f64], group_b: &[f64]) -> (f64, f64) {
1005    let mut pooled: Vec<(f64, bool)> = group_a
1006        .iter()
1007        .map(|&v| (v, true))
1008        .chain(group_b.iter().map(|&v| (v, false)))
1009        .collect();
1010    pooled.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
1011
1012    let mut rank_sum_a = 0.0_f64;
1013    let mut tie_correction = 0.0_f64;
1014    let mut index = 0usize;
1015    while index < pooled.len() {
1016        let mut end = index + 1;
1017        while end < pooled.len() && pooled[end].0 == pooled[index].0 {
1018            end += 1;
1019        }
1020        let tie_size = (end - index) as f64;
1021        // Mid-rank shared by the whole tie group (ranks are 1-based).
1022        let mid_rank = (index as f64 + 1.0 + end as f64) / 2.0;
1023        for entry in &pooled[index..end] {
1024            if entry.1 {
1025                rank_sum_a += mid_rank;
1026            }
1027        }
1028        if tie_size > 1.0 {
1029            tie_correction += tie_size * tie_size * tie_size - tie_size;
1030        }
1031        index = end;
1032    }
1033
1034    (rank_sum_a, tie_correction)
1035}
1036
1037impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
1038    for MannWhitneyUTest<A>
1039{
1040    fn test_for_drift(
1041        &mut self,
1042        reference: &[A],
1043        current: &[A],
1044    ) -> Result<DriftTestResult<A>, String> {
1045        let reference_sample = finite_f64(reference);
1046        let current_sample = finite_f64(current);
1047        if reference_sample.is_empty() || current_sample.is_empty() {
1048            return Err("Mann-Whitney U: both samples must be non-empty".to_string());
1049        }
1050
1051        let n1 = reference_sample.len() as f64;
1052        let n2 = current_sample.len() as f64;
1053        let total = n1 + n2;
1054
1055        let (rank_sum, tie_correction) = rank_sum_with_ties(&reference_sample, &current_sample);
1056        let u = rank_sum - n1 * (n1 + 1.0) / 2.0;
1057        let mean_u = n1 * n2 / 2.0;
1058
1059        let tie_term = if total > 1.0 {
1060            tie_correction / (total * (total - 1.0))
1061        } else {
1062            0.0
1063        };
1064        let variance_u = (n1 * n2 / 12.0) * ((total + 1.0) - tie_term);
1065
1066        let z = if variance_u > 0.0 {
1067            // Continuity correction of 0.5 towards the mean.
1068            let deviation = (u - mean_u).abs();
1069            (deviation - 0.5).max(0.0) / variance_u.sqrt()
1070        } else {
1071            0.0
1072        };
1073        let p_value = stats::normal_two_sided_p(z)?;
1074
1075        let mut metadata = HashMap::new();
1076        metadata.insert("u_statistic".to_string(), from_f64::<A>(u)?);
1077        metadata.insert("expected_u".to_string(), from_f64::<A>(mean_u)?);
1078        metadata.insert("tie_correction".to_string(), from_f64::<A>(tie_correction)?);
1079
1080        Ok(DriftTestResult {
1081            drift_detected: p_value < self.significance_level,
1082            // Report the standardised statistic so the magnitude is comparable
1083            // across sample sizes.
1084            test_statistic: from_f64(z)?,
1085            p_value: from_f64(p_value)?,
1086            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1087            metadata,
1088        })
1089    }
1090
1091    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
1092        let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
1093        self.significance_level =
1094            (self.significance_level * (1.0 + 0.1 * feedback)).clamp(1e-6, 0.5);
1095        Ok(())
1096    }
1097
1098    fn reset(&mut self) {
1099        // Stateless.
1100    }
1101}
1102
1103/// Builds a "no evidence of drift" result carrying a real statistic value.
1104fn insignificant_result<A: Float + Send + Sync>(
1105    statistic: f64,
1106    metadata: HashMap<String, A>,
1107) -> Result<DriftTestResult<A>, String> {
1108    Ok(DriftTestResult {
1109        drift_detected: false,
1110        p_value: A::one(),
1111        test_statistic: from_f64(statistic)?,
1112        confidence: A::zero(),
1113        metadata,
1114    })
1115}
1116
1117// ---------------------------------------------------------------------------
1118// Distribution comparators
1119// ---------------------------------------------------------------------------
1120
1121/// Divergence measures a [`HistogramComparator`] can evaluate.
1122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1123pub enum HistogramDivergence {
1124    /// Kullback-Leibler divergence `KL(current || reference)`, in nats.
1125    KullbackLeibler,
1126    /// Jensen-Shannon divergence, in nats (bounded by `ln 2`).
1127    JensenShannon,
1128    /// Hellinger distance (bounded by `1`).
1129    Hellinger,
1130}
1131
1132impl HistogramDivergence {
1133    /// Upper bound of the measure, used to normalise the drift threshold.
1134    /// `None` means the measure is unbounded (KL divergence).
1135    fn upper_bound(self) -> Option<f64> {
1136        match self {
1137            HistogramDivergence::KullbackLeibler => None,
1138            HistogramDivergence::JensenShannon => Some(std::f64::consts::LN_2),
1139            HistogramDivergence::Hellinger => Some(1.0),
1140        }
1141    }
1142
1143    fn label(self) -> &'static str {
1144        match self {
1145            HistogramDivergence::KullbackLeibler => "kl_divergence",
1146            HistogramDivergence::JensenShannon => "js_divergence",
1147            HistogramDivergence::Hellinger => "hellinger_distance",
1148        }
1149    }
1150}
1151
1152/// Histogram-based distribution comparator.
1153///
1154/// Both samples are binned onto a **common** support (the union of their
1155/// ranges) so the resulting probability mass functions are directly
1156/// comparable, additively smoothed so no bucket is exactly zero, and then
1157/// reduced to the requested divergence. Alongside the distance the comparator
1158/// runs a G-test (likelihood-ratio) on the same `2 x k` table, which yields a
1159/// genuine chi-square p-value rather than a rescaled distance.
1160pub struct HistogramComparator<A: Float + Send + Sync> {
1161    divergence: HistogramDivergence,
1162    threshold: A,
1163    bins: usize,
1164    _marker: std::marker::PhantomData<A>,
1165}
1166
1167impl<A: Float + Send + Sync> HistogramComparator<A> {
1168    /// Creates a comparator. `sensitivity` in `(0, 1]` is mapped onto the
1169    /// measure's own scale: for a bounded measure the threshold is that
1170    /// fraction of the bound, and for the unbounded KL divergence it is used
1171    /// directly in nats.
1172    pub fn new(divergence: HistogramDivergence, sensitivity: f64) -> Result<Self, String> {
1173        if !(sensitivity.is_finite() && sensitivity > 0.0) {
1174            return Err(format!(
1175                "{} sensitivity must be positive, got {sensitivity}",
1176                divergence.label()
1177            ));
1178        }
1179        let threshold_value = match divergence.upper_bound() {
1180            Some(bound) => (sensitivity.min(1.0)) * bound,
1181            None => sensitivity,
1182        };
1183        Ok(Self {
1184            divergence,
1185            threshold: from_f64(threshold_value)?,
1186            bins: HISTOGRAM_BINS,
1187            _marker: std::marker::PhantomData,
1188        })
1189    }
1190
1191    /// The divergence measure this comparator evaluates.
1192    pub fn divergence(&self) -> HistogramDivergence {
1193        self.divergence
1194    }
1195}
1196
1197impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> DistributionComparator<A>
1198    for HistogramComparator<A>
1199{
1200    fn compare_distributions(
1201        &self,
1202        reference: &[A],
1203        current: &[A],
1204    ) -> Result<DistributionComparison<A>, String> {
1205        let reference_sample = finite_f64(reference);
1206        let current_sample = finite_f64(current);
1207        if reference_sample.is_empty() || current_sample.is_empty() {
1208            return Err(format!(
1209                "{}: both samples must be non-empty",
1210                self.divergence.label()
1211            ));
1212        }
1213
1214        // Common support over the union of both samples.
1215        let (ref_min, ref_max) = stats::finite_range(&reference_sample)
1216            .ok_or_else(|| "reference sample has no finite observations".to_string())?;
1217        let (cur_min, cur_max) = stats::finite_range(&current_sample)
1218            .ok_or_else(|| "current sample has no finite observations".to_string())?;
1219        let min = ref_min.min(cur_min);
1220        let max = ref_max.max(cur_max);
1221
1222        let reference_counts = stats::histogram_counts(&reference_sample, min, max, self.bins);
1223        let current_counts = stats::histogram_counts(&current_sample, min, max, self.bins);
1224
1225        let reference_pmf = stats::smoothed_pmf(&reference_counts, HISTOGRAM_SMOOTHING);
1226        let current_pmf = stats::smoothed_pmf(&current_counts, HISTOGRAM_SMOOTHING);
1227
1228        let distance = match self.divergence {
1229            HistogramDivergence::KullbackLeibler => {
1230                stats::kl_divergence(&current_pmf, &reference_pmf)?
1231            }
1232            HistogramDivergence::JensenShannon => {
1233                stats::js_divergence(&reference_pmf, &current_pmf)?
1234            }
1235            HistogramDivergence::Hellinger => {
1236                stats::hellinger_distance(&reference_pmf, &current_pmf)?
1237            }
1238        };
1239
1240        // Real significance on the same binning.
1241        let (g, degrees_of_freedom) = stats::g_test_statistic(&reference_counts, &current_counts)?;
1242        let p_value = if degrees_of_freedom >= 1.0 {
1243            stats::chi_square_sf(g, degrees_of_freedom)?
1244        } else {
1245            1.0
1246        };
1247
1248        let threshold = to_f64(self.threshold)?;
1249        Ok(DistributionComparison {
1250            distance: from_f64(distance)?,
1251            threshold: self.threshold,
1252            drift_detected: distance > threshold,
1253            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1254        })
1255    }
1256
1257    fn get_threshold(&self) -> A {
1258        self.threshold
1259    }
1260
1261    fn update_threshold(&mut self, new_threshold: A) {
1262        self.threshold = new_threshold;
1263    }
1264}
1265
1266/// One-dimensional optimal-transport comparator.
1267///
1268/// Evaluates the first Wasserstein distance
1269/// `W1 = integral |F_ref(x) - F_cur(x)| dx` exactly on the merged support of
1270/// the two empirical distributions. In one dimension the Earth Mover's
1271/// Distance **is** `W1` — they are the same quantity, not two different
1272/// numbers — so both `DistributionMethod::WassersteinDistance` and
1273/// `DistributionMethod::EarthMoverDistance` are served by this comparator, and
1274/// the only difference between the two registrations is how the drift
1275/// threshold is scaled.
1276pub struct WassersteinComparator<A: Float + Send + Sync> {
1277    /// Threshold expressed as a fraction of the reference sample's spread, so
1278    /// the comparator is scale-free.
1279    relative_threshold: f64,
1280    threshold: A,
1281}
1282
1283impl<A: Float + Send + Sync> WassersteinComparator<A> {
1284    /// Creates a comparator whose drift threshold is `sensitivity` times the
1285    /// reference sample's standard deviation.
1286    pub fn new(sensitivity: f64) -> Result<Self, String> {
1287        if !(sensitivity.is_finite() && sensitivity > 0.0) {
1288            return Err(format!(
1289                "Wasserstein sensitivity must be positive, got {sensitivity}"
1290            ));
1291        }
1292        Ok(Self {
1293            relative_threshold: sensitivity,
1294            threshold: from_f64(sensitivity)?,
1295        })
1296    }
1297}
1298
1299impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> DistributionComparator<A>
1300    for WassersteinComparator<A>
1301{
1302    fn compare_distributions(
1303        &self,
1304        reference: &[A],
1305        current: &[A],
1306    ) -> Result<DistributionComparison<A>, String> {
1307        let reference_sample = finite_f64(reference);
1308        let current_sample = finite_f64(current);
1309        if reference_sample.is_empty() || current_sample.is_empty() {
1310            return Err("Wasserstein distance: both samples must be non-empty".to_string());
1311        }
1312
1313        let distance = stats::wasserstein_1d(&reference_sample, &current_sample)
1314            .ok_or_else(|| "Wasserstein distance is undefined for these samples".to_string())?;
1315
1316        // Scale-free threshold: a fraction of the reference spread.
1317        let spread = stats::sample_std_dev(&reference_sample).unwrap_or(0.0);
1318        let effective_threshold = if spread > 0.0 {
1319            self.relative_threshold * spread
1320        } else {
1321            to_f64(self.threshold)?
1322        };
1323
1324        // Significance comes from the two-sample KS test on the same data:
1325        // W1 measures *how far* the distributions moved, KS says whether the
1326        // shift is distinguishable from sampling noise.
1327        let d = stats::ks_statistic(&reference_sample, &current_sample)
1328            .ok_or_else(|| "Wasserstein: empirical CDF is undefined".to_string())?;
1329        let p_value = stats::ks_two_sample_p(d, reference_sample.len(), current_sample.len());
1330
1331        Ok(DistributionComparison {
1332            distance: from_f64(distance)?,
1333            threshold: from_f64(effective_threshold)?,
1334            drift_detected: distance > effective_threshold,
1335            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1336        })
1337    }
1338
1339    fn get_threshold(&self) -> A {
1340        self.threshold
1341    }
1342
1343    fn update_threshold(&mut self, new_threshold: A) {
1344        self.threshold = new_threshold;
1345        if let Some(value) = new_threshold.to_f64() {
1346            if value > 0.0 {
1347                self.relative_threshold = value;
1348            }
1349        }
1350    }
1351}
1352
1353// ---------------------------------------------------------------------------
1354// Model-based detector
1355// ---------------------------------------------------------------------------
1356
1357/// Online linear model drift detector.
1358///
1359/// Fits a real ridge-regularised linear regressor by stochastic gradient
1360/// descent on the incoming `(features -> target)` pairs and monitors its
1361/// prediction error. `baseline_performance` is the exponentially-weighted mean
1362/// squared error established while the model was last considered healthy;
1363/// `model_performance` is the EWMA of the most recent errors. When the recent
1364/// error rises significantly above the baseline the model has stopped
1365/// describing the stream, which is precisely model-based drift.
1366///
1367/// Both fields are genuinely written by `update_model`, so
1368/// `performance_degradation` is a real measurement rather than the constant
1369/// zero produced by subtracting two never-initialised accumulators.
1370pub struct LinearModelDetector<A: Float + Send + Sync> {
1371    /// Regression weights (grown lazily to the observed feature width).
1372    weights: Vec<f64>,
1373    /// Intercept term.
1374    bias: f64,
1375    /// Step size of the normalised-LMS update. Stable for `0 < mu < 2`.
1376    learning_rate: f64,
1377    /// L2 weight-decay coefficient.
1378    l2_lambda: f64,
1379    /// EWMA smoothing factor for the error trackers.
1380    error_alpha: f64,
1381    /// EWMA of squared prediction error over the healthy baseline period.
1382    baseline_performance: f64,
1383    /// EWMA of squared prediction error over the most recent observations.
1384    model_performance: f64,
1385    /// Running variance of the squared error, for the significance test.
1386    error_m2: f64,
1387    error_mean: f64,
1388    /// Number of supervised updates applied.
1389    updates: usize,
1390    /// Number of updates required before the baseline is trusted.
1391    warmup_updates: usize,
1392    /// Weight snapshot taken at baseline time, for feature-importance deltas.
1393    baseline_weights: Vec<f64>,
1394    /// Relative degradation that counts as drift.
1395    degradation_threshold: f64,
1396    _marker: std::marker::PhantomData<A>,
1397}
1398
1399impl<A: Float + Send + Sync> LinearModelDetector<A> {
1400    /// Creates a detector. `sensitivity` becomes the relative error-increase
1401    /// that counts as drift (e.g. `0.05` = a 5% rise in mean squared error).
1402    pub fn new(sensitivity: f64) -> Result<Self, String> {
1403        if !(sensitivity.is_finite() && sensitivity > 0.0) {
1404            return Err(format!(
1405                "linear model sensitivity must be positive, got {sensitivity}"
1406            ));
1407        }
1408        Ok(Self {
1409            weights: Vec::new(),
1410            bias: 0.0,
1411            // Normalised-LMS step size; the classic mid-range choice, stable for
1412            // any input scale.
1413            learning_rate: 0.5,
1414            l2_lambda: 1e-5,
1415            error_alpha: 0.1,
1416            baseline_performance: f64::NAN,
1417            model_performance: f64::NAN,
1418            error_m2: 0.0,
1419            error_mean: 0.0,
1420            updates: 0,
1421            warmup_updates: 20,
1422            baseline_weights: Vec::new(),
1423            degradation_threshold: sensitivity,
1424            _marker: std::marker::PhantomData,
1425        })
1426    }
1427
1428    /// Current squared-error EWMA, or `None` before the first supervised
1429    /// update.
1430    pub fn current_error(&self) -> Option<f64> {
1431        if self.model_performance.is_finite() {
1432            Some(self.model_performance)
1433        } else {
1434            None
1435        }
1436    }
1437
1438    /// Baseline squared-error EWMA, or `None` before warm-up completes.
1439    pub fn baseline_error(&self) -> Option<f64> {
1440        if self.baseline_performance.is_finite() {
1441            Some(self.baseline_performance)
1442        } else {
1443            None
1444        }
1445    }
1446
1447    fn predict(&self, features: &[f64]) -> f64 {
1448        let mut prediction = self.bias;
1449        for (weight, &feature) in self.weights.iter().zip(features.iter()) {
1450            prediction += weight * feature;
1451        }
1452        prediction
1453    }
1454
1455    /// Applies one normalised-LMS step and folds the observed error into the
1456    /// trackers.
1457    ///
1458    /// Plain SGD on a raw feature vector converges at a rate that depends on the
1459    /// feature scale, which is unknown for a streaming metric. The normalised
1460    /// least-mean-squares update divides the step by the instantaneous input
1461    /// energy `1 + ||x||^2` (the `1` accounting for the bias coordinate), which
1462    /// makes the step size scale-invariant and stable for any
1463    /// `0 < learning_rate < 2` — the standard choice for an online regressor
1464    /// whose inputs are not pre-standardised. The ridge penalty is applied as a
1465    /// separate weight decay so it does not interact with the normalisation.
1466    fn learn_one(&mut self, features: &[f64], target: f64) {
1467        if self.weights.len() < features.len() {
1468            self.weights.resize(features.len(), 0.0);
1469        }
1470
1471        let prediction = self.predict(features);
1472        let error = prediction - target;
1473        let squared_error = error * error;
1474
1475        let input_energy = 1.0 + features.iter().map(|&f| f * f).sum::<f64>();
1476        let step = self.learning_rate * error / input_energy;
1477        for (weight, &feature) in self.weights.iter_mut().zip(features.iter()) {
1478            *weight -= step * feature + self.l2_lambda * *weight;
1479        }
1480        self.bias -= step;
1481
1482        // EWMA of the squared error, plus a Welford accumulator for its spread.
1483        self.model_performance = if self.model_performance.is_finite() {
1484            self.error_alpha * squared_error + (1.0 - self.error_alpha) * self.model_performance
1485        } else {
1486            squared_error
1487        };
1488
1489        self.updates += 1;
1490        let delta = squared_error - self.error_mean;
1491        self.error_mean += delta / self.updates as f64;
1492        self.error_m2 += delta * (squared_error - self.error_mean);
1493
1494        // Establish (and afterwards slowly track) the healthy baseline.
1495        if self.updates == self.warmup_updates || !self.baseline_performance.is_finite() {
1496            self.baseline_performance = self.model_performance;
1497            self.baseline_weights = self.weights.clone();
1498        } else if self.model_performance <= self.baseline_performance {
1499            // The model is doing at least as well as its baseline, so tighten
1500            // the baseline towards the current level.
1501            self.baseline_performance =
1502                0.9 * self.baseline_performance + 0.1 * self.model_performance;
1503            self.baseline_weights = self.weights.clone();
1504        }
1505    }
1506
1507    /// Extracts a supervised `(features, target)` pair from a data point.
1508    ///
1509    /// When the point carries no target the model cannot be trained, so the
1510    /// point is skipped rather than silently trained against a fabricated
1511    /// label.
1512    fn supervised_pair(data_point: &StreamingDataPoint<A>) -> Option<(Vec<f64>, f64)> {
1513        let target = data_point.target.as_ref()?;
1514        let target_value = target.iter().next()?.to_f64()?;
1515        if !target_value.is_finite() {
1516            return None;
1517        }
1518        let features: Vec<f64> = data_point
1519            .features
1520            .iter()
1521            .filter_map(|v| v.to_f64())
1522            .filter(|v| v.is_finite())
1523            .collect();
1524        if features.is_empty() {
1525            return None;
1526        }
1527        Some((features, target_value))
1528    }
1529}
1530
1531impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
1532    for LinearModelDetector<A>
1533{
1534    fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
1535        let mut trained = 0usize;
1536        for data_point in data {
1537            if let Some((features, target)) = Self::supervised_pair(data_point) {
1538                self.learn_one(&features, target);
1539                trained += 1;
1540            }
1541        }
1542        if trained == 0 && !data.is_empty() {
1543            return Err(
1544                "linear model drift detector requires labelled data points (target is None)"
1545                    .to_string(),
1546            );
1547        }
1548        Ok(())
1549    }
1550
1551    fn detect_drift(
1552        &mut self,
1553        data: &[StreamingDataPoint<A>],
1554    ) -> Result<ModelDriftResult<A>, String> {
1555        // Learn from the batch first so the error trackers reflect it.
1556        self.update_model(data)?;
1557
1558        let baseline = self
1559            .baseline_performance
1560            .is_finite()
1561            .then_some(self.baseline_performance)
1562            .ok_or_else(|| "linear model drift detector has no baseline yet".to_string())?;
1563        let current = self
1564            .model_performance
1565            .is_finite()
1566            .then_some(self.model_performance)
1567            .ok_or_else(|| "linear model drift detector has no error estimate yet".to_string())?;
1568
1569        // Relative rise in mean squared error.
1570        let denominator = baseline.max(f64::MIN_POSITIVE);
1571        let degradation = (current - baseline) / denominator;
1572
1573        // Significance: a one-sided z test that the recent squared error sits
1574        // above the baseline, using the running spread of the squared error.
1575        let variance = if self.updates > 1 {
1576            self.error_m2 / (self.updates - 1) as f64
1577        } else {
1578            0.0
1579        };
1580        let standard_error = if self.updates > 0 {
1581            (variance / self.updates as f64).sqrt()
1582        } else {
1583            0.0
1584        };
1585        let z = if standard_error > 0.0 {
1586            (current - baseline) / standard_error
1587        } else {
1588            0.0
1589        };
1590        let p_value = stats::standard_normal_sf(z)?;
1591
1592        let ready = self.updates >= self.warmup_updates;
1593        let drift_detected = ready && degradation > self.degradation_threshold;
1594
1595        // Real feature-importance change: the per-weight delta since baseline.
1596        let mut feature_importance_changes = Vec::with_capacity(self.weights.len());
1597        for (index, weight) in self.weights.iter().enumerate() {
1598            let baseline_weight = self.baseline_weights.get(index).copied().unwrap_or(0.0);
1599            feature_importance_changes.push(from_f64::<A>(weight - baseline_weight)?);
1600        }
1601
1602        Ok(ModelDriftResult {
1603            drift_detected,
1604            performance_degradation: from_f64(degradation)?,
1605            confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1606            feature_importance_changes,
1607        })
1608    }
1609
1610    fn reset_model(&mut self) -> Result<(), String> {
1611        self.weights.clear();
1612        self.baseline_weights.clear();
1613        self.bias = 0.0;
1614        self.baseline_performance = f64::NAN;
1615        self.model_performance = f64::NAN;
1616        self.error_mean = 0.0;
1617        self.error_m2 = 0.0;
1618        self.updates = 0;
1619        Ok(())
1620    }
1621}
1622
1623#[cfg(test)]
1624#[path = "drift_tests_regression_tests.rs"]
1625mod regression_tests;