Skip to main content

chronix_analytics/lifecycle/
drift.rs

1//! Distribution drift detection for model monitoring.
2//!
3//! Three techniques:
4//!
5//! - **PSI** (Population Stability Index): compares binned distributions
6//!   of current vs training data.  Threshold ≥ 0.25 → significant drift.
7//! - **Kolmogorov–Smirnov**: two-sample KS test on CDFs.
8//! - **ADWIN** (Adaptive Windowing): online algorithm that detects concept
9//!   drift in a data stream by maintaining adaptive windows.
10
11use parking_lot::Mutex;
12use std::sync::Arc;
13use std::time::Duration;
14
15/// Action to take when drift is detected.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub enum DriftAction {
18    /// No drift detected — do nothing.
19    NoAction,
20    /// Drift detected — trigger model re-fit.
21    RetriggerFit,
22    /// Mild drift — emit alert but don't re-fit yet.
23    AlertOnly,
24}
25
26/// Complete drift analysis report.
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct ModelDriftReport {
29    /// Detector that produced this report.
30    pub detector_name: String,
31    /// Drift score (PSI value, KS statistic, or ADWIN `epsilon`).
32    pub score: f64,
33    /// Whether drift was detected.
34    pub drift_detected: bool,
35    /// Recommended action.
36    pub action: DriftAction,
37}
38
39/// Drift detection strategy.
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub enum DriftDetector {
42    /// Population Stability Index with a configurable threshold (default 0.25).
43    Psi {
44        /// PSI threshold for drift detection.
45        threshold: f64,
46    },
47    /// Kolmogorov–Smirnov two-sample test with a configurable p-value
48    /// significance level (default 0.05).
49    KolmogorovSmirnov {
50        /// Significance level for the KS test.
51        p_value: f64,
52    },
53    /// ADWIN online adaptive windowing with a configurable delta (default 0.002).
54    Adwin {
55        /// ADWIN confidence parameter.
56        delta: f64,
57    },
58}
59
60impl Default for DriftDetector {
61    fn default() -> Self {
62        Self::Psi { threshold: 0.25 }
63    }
64}
65
66impl DriftDetector {
67    /// Run drift detection.
68    ///
69    /// - `training`: the reference distribution the model was trained on.
70    /// - `current`: the current/production data.
71    pub fn detect(&self, training: &[f64], current: &[f64]) -> ModelDriftReport {
72        let report = match self {
73            Self::Psi { threshold } => psi_detect(training, current, *threshold),
74            Self::KolmogorovSmirnov { p_value } => ks_detect(training, current, *p_value),
75            Self::Adwin { delta } => adwin_detect(training, current, *delta),
76        };
77        if report.drift_detected {
78            metrics::counter!("chronix_model_drift_detected_total", "method" => report.detector_name.clone()).increment(1);
79        }
80        metrics::gauge!("chronix_model_drift_score", "method" => report.detector_name.clone())
81            .set(report.score);
82        report
83    }
84}
85
86// ── PSI ─────────────────────────────────────────────────────────────────
87
88fn psi_detect(training: &[f64], current: &[f64], threshold: f64) -> ModelDriftReport {
89    let n_bins = 10;
90    let psi = compute_psi(training, current, n_bins);
91    let drift_detected = psi >= threshold;
92    let action = if psi >= threshold {
93        DriftAction::RetriggerFit
94    } else if psi >= threshold * 0.4 {
95        DriftAction::AlertOnly
96    } else {
97        DriftAction::NoAction
98    };
99
100    ModelDriftReport {
101        detector_name: "PSI".into(),
102        score: psi,
103        drift_detected,
104        action,
105    }
106}
107
108/// Compute PSI between two distributions using quantile-based
109/// (equal-frequency) bins derived from the *training* data.
110///
111/// Quantile-based bins adapt to the shape of the training distribution,
112/// producing more meaningful PSI values when data is skewed.
113fn compute_psi(training: &[f64], current: &[f64], n_bins: usize) -> f64 {
114    if training.is_empty() || current.is_empty() || n_bins == 0 {
115        return 0.0;
116    }
117
118    let mut sorted_train: Vec<f64> = training.to_vec();
119    sorted_train.sort_by(f64::total_cmp);
120
121    let edges = quantile_bin_edges(&sorted_train, n_bins);
122
123    let t_counts = bin_counts_by_edges(training, &edges);
124    let c_counts = bin_counts_by_edges(current, &edges);
125
126    let t_total: f64 = t_counts.iter().sum();
127    let c_total: f64 = c_counts.iter().sum();
128    let eps = 1e-4; // smoothing constant – avoids log(0)
129
130    if t_total == 0.0 || c_total == 0.0 {
131        return 0.0;
132    }
133
134    let mut psi = 0.0;
135    for i in 0..t_counts.len() {
136        let p = (t_counts[i] / t_total).max(eps);
137        let q = (c_counts[i] / c_total).max(eps);
138        psi += (q - p) * (q / p).ln();
139    }
140
141    psi.max(0.0)
142}
143
144/// Compute bin edges from percentiles of *sorted* training data.
145///
146/// Returns `n_bins + 1` edges such that each bin captures approximately
147/// the same number of training observations (equal-frequency binning).
148fn quantile_bin_edges(sorted_data: &[f64], n_bins: usize) -> Vec<f64> {
149    let n = sorted_data.len();
150    let mut edges = Vec::with_capacity(n_bins + 1);
151    for i in 0..=n_bins {
152        let idx = (i * n / n_bins).min(n.saturating_sub(1));
153        edges.push(sorted_data[idx]);
154    }
155    // Ensure the last edge is slightly above the max so that the
156    // maximum value falls inside the last bin.
157    if let Some(last) = edges.last_mut() {
158        *last += f64::EPSILON;
159    }
160    // Repeated edges are left in place, and that is deliberate.
161    //
162    // Equal-frequency edges repeat wherever the training data is quantised —
163    // a gauge resting at zero, a state of charge pinned at 100 %. The obvious
164    // worry is that a repeated edge is a bin nothing can fall into, so its
165    // reference share floors at `eps` while its current share does not, and
166    // `(q−p)·ln(q/p)` invents drift. It does not happen: a zero-width bin is
167    // unreachable for *both* samples, so `p == q == eps` and the term is
168    // zero. Collapsing them would change every PSI value against the
169    // conventional 0.1 / 0.2 thresholds in exchange for nothing, so the
170    // hypothesis is recorded here rather than acted on.
171    edges
172}
173
174/// Count how many values fall into each bin defined by `edges`.
175///
176/// `edges` has length `n_bins + 1`.  Bin *i* spans `[edges[i], edges[i+1])`.
177/// Values below the first edge go into bin 0; values at or above the last
178/// edge go into the final bin.  Returns `n_bins` counts as `f64`.
179fn bin_counts_by_edges(data: &[f64], edges: &[f64]) -> Vec<f64> {
180    let n_bins = edges.len() - 1;
181    let mut counts = vec![0.0_f64; n_bins];
182    for &v in data {
183        if v.is_nan() {
184            continue;
185        }
186        // Binary-search for the right bin
187        let mut idx = match edges.binary_search_by(|e| e.total_cmp(&v)) {
188            Ok(i) => i,  // exactly on an edge
189            Err(i) => i, // insertion point
190        };
191        // Value before first edge → bin 0
192        if idx == 0 {
193            counts[0] += 1.0;
194            continue;
195        }
196        // Value at or beyond last edge → last bin
197        if idx >= n_bins {
198            counts[n_bins - 1] += 1.0;
199            continue;
200        }
201        // Normal case: value falls in bin (idx - 1)
202        // edges layout: [e0, e1, …, en] → bin i covers [ei, ei+1)
203        // binary_search returns the idx where v would be inserted,
204        // so the bin index is idx - 1.
205        idx -= 1;
206        counts[idx] += 1.0;
207    }
208    counts
209}
210
211// ── Kolmogorov–Smirnov ──────────────────────────────────────────────────
212
213fn ks_detect(training: &[f64], current: &[f64], p_threshold: f64) -> ModelDriftReport {
214    if training.is_empty() || current.is_empty() {
215        return ModelDriftReport {
216            detector_name: "KolmogorovSmirnov".into(),
217            score: 0.0,
218            drift_detected: false,
219            action: DriftAction::NoAction,
220        };
221    }
222
223    let ks_stat = ks_statistic(training, current);
224
225    // The decision is the p-value against the configured threshold, which is
226    // what a field named `p_threshold` means. It used to be a three-entry
227    // lookup — 1.63 / 1.36 / 1.22 for α ≤ 0.01 / 0.05 / anything else — so
228    // every α above 0.05 got the same 0.10 critical value and the reported
229    // score was a statistic the threshold was not comparable to. The closed
230    // form reproduces all three constants exactly and works for any α.
231    let drift_detected = ks_p_value(ks_stat, training.len(), current.len()) < p_threshold;
232    let action = if drift_detected {
233        DriftAction::RetriggerFit
234    } else {
235        DriftAction::NoAction
236    };
237
238    ModelDriftReport {
239        detector_name: "KolmogorovSmirnov".into(),
240        score: ks_stat,
241        drift_detected,
242        action,
243    }
244}
245
246/// Asymptotic p-value for a two-sample Kolmogorov–Smirnov statistic.
247///
248/// `Q(λ) = 2 Σ_{k≥1} (−1)^{k−1} e^{−2k²λ²}` evaluated at
249/// `λ = D·(√nₑ + 0.12 + 0.11/√nₑ)` with the effective size
250/// `nₑ = n₁n₂/(n₁+n₂)` — the Stephens correction, as in *Numerical Recipes*
251/// §14.3. The series converges geometrically; 100 terms is far beyond
252/// double precision for any λ that reaches the loop.
253///
254/// The equivalent critical value is `sqrt(−ln(α/2)/2)·sqrt((n₁+n₂)/(n₁n₂))`,
255/// which is where the familiar 1.358 / 1.628 / 1.224 constants come from.
256fn ks_p_value(d: f64, n1: usize, n2: usize) -> f64 {
257    if n1 == 0 || n2 == 0 || !d.is_finite() {
258        return 1.0;
259    }
260    let n_eff = (n1 as f64 * n2 as f64) / (n1 as f64 + n2 as f64);
261    let sqrt_n = n_eff.sqrt();
262    let lambda = d * (sqrt_n + 0.12 + 0.11 / sqrt_n);
263    if lambda <= 0.0 {
264        return 1.0;
265    }
266
267    let mut sum = 0.0;
268    let mut sign = 1.0;
269    for k in 1..=100 {
270        let term = (-2.0 * f64::from(k) * f64::from(k) * lambda * lambda).exp();
271        sum += sign * term;
272        if term < 1e-12 * sum.abs() || term < f64::MIN_POSITIVE {
273            break;
274        }
275        sign = -sign;
276    }
277    (2.0 * sum).clamp(0.0, 1.0)
278}
279
280/// Two-sample KS statistic: max |F_1(x) - F_2(x)|.
281fn ks_statistic(a: &[f64], b: &[f64]) -> f64 {
282    if a.is_empty() || b.is_empty() {
283        return 0.0;
284    }
285
286    let mut sa: Vec<f64> = a.to_vec();
287    sa.sort_by(f64::total_cmp);
288    let mut sb: Vec<f64> = b.to_vec();
289    sb.sort_by(f64::total_cmp);
290
291    let na = sa.len() as f64;
292    let nb = sb.len() as f64;
293
294    let mut ia = 0usize;
295    let mut ib = 0usize;
296    let mut max_diff = 0.0_f64;
297
298    while ia < sa.len() && ib < sb.len() {
299        if sa[ia] <= sb[ib] {
300            ia += 1;
301        } else {
302            ib += 1;
303        }
304        let fa = ia as f64 / na;
305        let fb = ib as f64 / nb;
306        max_diff = max_diff.max((fa - fb).abs());
307    }
308
309    // Handle remaining elements
310    while ia < sa.len() {
311        let fa = (ia + 1) as f64 / na;
312        let fb = ib as f64 / nb;
313        max_diff = max_diff.max((fa - fb).abs());
314        ia += 1;
315    }
316    while ib < sb.len() {
317        let fa = ia as f64 / na;
318        let fb = (ib + 1) as f64 / nb;
319        max_diff = max_diff.max((fa - fb).abs());
320        ib += 1;
321    }
322
323    max_diff
324}
325
326// ── ADWIN ───────────────────────────────────────────────────────────────
327
328/// ADWIN's cut threshold, from Bifet & Gavaldà, *Learning from Time-Changing
329/// Data with Adaptive Windowing*, SDM 2007 (the ADWIN2 bound, §3.2):
330///
331/// ```text
332/// m  = 1 / (1/n0 + 1/n1)          harmonic mean of the two window sizes
333/// δ' = δ / n                       Bonferroni correction over the n cut points
334/// ε  = sqrt( (2/m)·σ²_W·ln(2/δ') ) + (2/(3m))·ln(2/δ')
335/// ```
336///
337/// # Why the variance term is not optional
338///
339/// The implementation this replaces used `sqrt((2/m)·ln(4/δ))` — a Hoeffding
340/// bound with no `σ²` and no `δ/n`, which is two separate problems.
341///
342/// The missing `σ²` is the serious one: without it the threshold is a pure
343/// function of the window sizes, so it is *identical* for a series in `[0, 1]`
344/// and a power meter reading 200–250 W. On the meter it fires on essentially
345/// any window pair, and on a near-constant series it never fires at all. A
346/// drift detector whose sensitivity depends on the unit the data happens to be
347/// recorded in is not measuring drift.
348///
349/// The missing `δ/n` is the reason the bound holds at all: ADWIN tests every
350/// split point of the window, so the confidence has to be shared across them
351/// or the nominal false-positive rate is not the actual one.
352///
353/// `σ²_W` is the variance of the two windows combined, which is what the paper
354/// specifies — the statistic under test is a difference of means drawn from
355/// one window under the null hypothesis.
356fn adwin_epsilon_cut(training: &[f64], current: &[f64], delta: f64) -> f64 {
357    let n0 = training.len() as f64;
358    let n1 = current.len() as f64;
359    let n = n0 + n1;
360
361    // Pooled variance of the combined window, under the null that both halves
362    // came from one distribution.
363    let mean: f64 = (training.iter().sum::<f64>() + current.iter().sum::<f64>()) / n;
364    let variance: f64 = training
365        .iter()
366        .chain(current.iter())
367        .map(|v| (v - mean).powi(2))
368        .sum::<f64>()
369        / n;
370
371    let m = 1.0 / (1.0 / n0 + 1.0 / n1);
372    let delta_prime = (delta / n).clamp(f64::MIN_POSITIVE, 1.0);
373    let ln_term = (2.0 / delta_prime).ln();
374
375    ((2.0 / m) * variance * ln_term).sqrt() + (2.0 / (3.0 * m)) * ln_term
376}
377
378fn adwin_detect(training: &[f64], current: &[f64], delta: f64) -> ModelDriftReport {
379    // `training` is the reference window, `current` the test window; drift is
380    // a difference of means larger than the ADWIN2 cut allows.
381    if training.is_empty() || current.is_empty() {
382        return ModelDriftReport {
383            detector_name: "ADWIN".into(),
384            score: 0.0,
385            drift_detected: false,
386            action: DriftAction::NoAction,
387        };
388    }
389
390    let mean1 = training.iter().sum::<f64>() / training.len() as f64;
391    let mean2 = current.iter().sum::<f64>() / current.len() as f64;
392    let epsilon_cut = adwin_epsilon_cut(training, current, delta);
393
394    let diff = (mean1 - mean2).abs();
395    let drift_detected = diff > epsilon_cut;
396
397    let action = if drift_detected {
398        DriftAction::RetriggerFit
399    } else {
400        DriftAction::NoAction
401    };
402
403    ModelDriftReport {
404        detector_name: "ADWIN".into(),
405        score: diff,
406        drift_detected,
407        action,
408    }
409}
410
411// ── DriftMonitor ────────────────────────────────────────────────────────
412
413/// Callback invoked when drift is detected on a model.
414pub type DriftCallback = Box<dyn Fn(&str, &ModelDriftReport) + Send + Sync>;
415
416/// Background monitor that periodically checks registered models for data drift.
417///
418/// Each registered model has a training distribution snapshot. The monitor
419/// re-checks the current distribution against the training snapshot at the
420/// configured interval.
421///
422/// Snapshots are capped at `max_snapshot_size` to bound memory usage.
423/// When incoming data exceeds this limit, reservoir sampling retains a
424/// representative subset.
425pub struct DriftMonitor {
426    /// (measurement_name, detector, training_data, current_data_fn)
427    models: Arc<Mutex<Vec<MonitoredModel>>>,
428    interval: Duration,
429    callback: Arc<DriftCallback>,
430    max_snapshot_size: usize,
431}
432
433struct MonitoredModel {
434    measurement: String,
435    detector: DriftDetector,
436    training_snapshot: Vec<f64>,
437    current_snapshot: Vec<f64>,
438}
439
440/// Default maximum number of data points kept per snapshot.
441const DEFAULT_MAX_SNAPSHOT_SIZE: usize = 10_000;
442
443/// Downsample `data` in-place to at most `max_size` elements using
444/// deterministic stride-based sampling (fast, reproducible, no RNG).
445fn cap_snapshot(data: &mut Vec<f64>, max_size: usize) {
446    if data.len() <= max_size || max_size == 0 {
447        return;
448    }
449    let step = data.len() as f64 / max_size as f64;
450    let mut sampled = Vec::with_capacity(max_size);
451    for i in 0..max_size {
452        let idx = (i as f64 * step) as usize;
453        sampled.push(data[idx]);
454    }
455    *data = sampled;
456}
457
458impl DriftMonitor {
459    /// Creates a new `DriftMonitor` with the given check interval and callback.
460    pub fn new(interval: Duration, callback: DriftCallback) -> Self {
461        Self {
462            models: Arc::new(Mutex::new(Vec::new())),
463            interval,
464            callback: Arc::new(callback),
465            max_snapshot_size: DEFAULT_MAX_SNAPSHOT_SIZE,
466        }
467    }
468
469    /// Creates a new `DriftMonitor` with a custom snapshot size limit.
470    pub fn with_max_snapshot_size(
471        interval: Duration,
472        callback: DriftCallback,
473        max_snapshot_size: usize,
474    ) -> Self {
475        Self {
476            models: Arc::new(Mutex::new(Vec::new())),
477            interval,
478            callback: Arc::new(callback),
479            max_snapshot_size,
480        }
481    }
482
483    /// Registers a model for drift monitoring.
484    pub fn register(
485        &self,
486        measurement: &str,
487        detector: DriftDetector,
488        training_snapshot: Vec<f64>,
489    ) {
490        let mut snapshot = training_snapshot;
491        cap_snapshot(&mut snapshot, self.max_snapshot_size);
492        let mut models = self.models.lock();
493        models.push(MonitoredModel {
494            measurement: measurement.to_string(),
495            detector,
496            training_snapshot: snapshot,
497            current_snapshot: Vec::new(),
498        });
499    }
500
501    /// Updates the current data snapshot for a measurement.
502    pub fn update_current(&self, measurement: &str, data: Vec<f64>) {
503        let mut capped = data;
504        cap_snapshot(&mut capped, self.max_snapshot_size);
505        let mut models = self.models.lock();
506        for model in models.iter_mut() {
507            if model.measurement == measurement {
508                model.current_snapshot = capped.clone();
509            }
510        }
511    }
512
513    /// Runs a single check cycle — evaluates all registered models for drift.
514    /// Returns a list of drift reports for models where drift was detected.
515    pub fn check_all(&self) -> Vec<(String, ModelDriftReport)> {
516        // Collect reports under the lock, then release before invoking
517        // callbacks. This prevents deadlocks if a callback tries to call
518        // register() or update_current(), which also take the lock.
519        let reports: Vec<(String, ModelDriftReport)> = {
520            let models = self.models.lock();
521            models
522                .iter()
523                .filter(|m| !m.current_snapshot.is_empty())
524                .map(|m| {
525                    let report = m.detector.detect(&m.training_snapshot, &m.current_snapshot);
526                    (m.measurement.clone(), report)
527                })
528                .collect()
529        }; // ← lock released here
530
531        // Now invoke callbacks without holding any lock.
532        for (measurement, report) in &reports {
533            if report.drift_detected {
534                (self.callback)(measurement, report);
535            }
536        }
537
538        reports
539    }
540
541    /// Returns the configured check interval.
542    pub fn interval(&self) -> Duration {
543        self.interval
544    }
545}
546
547// -----------------------------------------------------------------------
548// Tests
549// -----------------------------------------------------------------------
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn test_psi_no_drift_same_distribution() {
556        let training: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0).collect();
557        let current: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0).collect();
558
559        let det = DriftDetector::Psi { threshold: 0.25 };
560        let report = det.detect(&training, &current);
561        assert!(!report.drift_detected);
562        assert!(report.score < 0.01);
563        assert_eq!(report.action, DriftAction::NoAction);
564    }
565
566    #[test]
567    fn test_psi_drift_detected_shifted() {
568        let training: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0).collect();
569        let current: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0 + 50.0).collect();
570
571        let det = DriftDetector::Psi { threshold: 0.25 };
572        let report = det.detect(&training, &current);
573        assert!(report.drift_detected);
574        assert!(report.score > 0.25);
575        assert_eq!(report.action, DriftAction::RetriggerFit);
576    }
577
578    #[test]
579    fn test_ks_same_distribution() {
580        let a: Vec<f64> = (0..500).map(|i| i as f64).collect();
581        let b: Vec<f64> = (0..500).map(|i| i as f64).collect();
582
583        let det = DriftDetector::KolmogorovSmirnov { p_value: 0.05 };
584        let report = det.detect(&a, &b);
585        assert!(!report.drift_detected);
586    }
587
588    #[test]
589    fn test_ks_drift_detected() {
590        let a: Vec<f64> = (0..500).map(|i| i as f64).collect();
591        let b: Vec<f64> = (0..500).map(|i| i as f64 + 1000.0).collect();
592
593        let det = DriftDetector::KolmogorovSmirnov { p_value: 0.05 };
594        let report = det.detect(&a, &b);
595        assert!(report.drift_detected);
596        assert_eq!(report.action, DriftAction::RetriggerFit);
597    }
598
599    #[test]
600    fn test_adwin_no_drift() {
601        let training = vec![5.0; 100];
602        let current = vec![5.0; 100];
603
604        let det = DriftDetector::Adwin { delta: 0.002 };
605        let report = det.detect(&training, &current);
606        assert!(!report.drift_detected);
607    }
608
609    #[test]
610    fn test_adwin_drift_detected() {
611        let training = vec![5.0; 100];
612        let current = vec![50.0; 100];
613
614        let det = DriftDetector::Adwin { delta: 0.002 };
615        let report = det.detect(&training, &current);
616        assert!(report.drift_detected);
617        assert_eq!(report.action, DriftAction::RetriggerFit);
618    }
619
620    #[test]
621    fn test_ks_statistic_identical() {
622        let a: Vec<f64> = (0..100).map(|i| i as f64).collect();
623        let stat = ks_statistic(&a, &a);
624        assert!(stat < 0.02);
625    }
626
627    #[test]
628    fn test_ks_statistic_disjoint() {
629        let a: Vec<f64> = (0..100).map(|i| i as f64).collect();
630        let b: Vec<f64> = (200..300).map(|i| i as f64).collect();
631        let stat = ks_statistic(&a, &b);
632        assert!((stat - 1.0).abs() < 0.02);
633    }
634
635    #[test]
636    fn test_psi_alert_only() {
637        // Create a mild shift that triggers AlertOnly, not RetriggerFit
638        let training: Vec<f64> = (0..2000).map(|i| i as f64).collect();
639        // Shift ~10% of the range
640        let current: Vec<f64> = (0..2000).map(|i| i as f64 + 200.0).collect();
641
642        let det = DriftDetector::Psi { threshold: 0.25 };
643        let report = det.detect(&training, &current);
644
645        // This might be AlertOnly or NoAction depending on the shift magnitude
646        assert!(report.action != DriftAction::RetriggerFit || report.score >= 0.25);
647    }
648
649    /// ADWIN must decide the same way on the same data in different units.
650    ///
651    /// The bound it replaced had no `σ²` term, so its threshold depended only
652    /// on the window sizes: identical for a series in `[0, 1]` and for the
653    /// same series expressed in watts. Scaling the data by 100 turned "no
654    /// drift" into "drift" without changing anything about the data.
655    #[test]
656    fn adwin_is_scale_invariant() {
657        let mut seed = 7u64;
658        let mut noise = || {
659            seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
660            ((seed >> 33) as f64 / f64::from(u32::MAX)) - 0.5
661        };
662        let base: Vec<f64> = (0..200).map(|_| 0.5 + noise() * 0.2).collect();
663        let (a, b) = base.split_at(100);
664
665        for scale in [1.0, 100.0, 10_000.0] {
666            let sa: Vec<f64> = a.iter().map(|v| v * scale).collect();
667            let sb: Vec<f64> = b.iter().map(|v| v * scale).collect();
668            let report = DriftDetector::Adwin { delta: 0.002 }.detect(&sa, &sb);
669            assert!(
670                !report.drift_detected,
671                "no drift at scale {scale}: two halves of one distribution"
672            );
673        }
674
675        // …and a genuine shift is still caught at every scale.
676        for scale in [1.0, 100.0, 10_000.0] {
677            let sa: Vec<f64> = a.iter().map(|v| v * scale).collect();
678            let shifted: Vec<f64> = b.iter().map(|v| (v + 1.0) * scale).collect();
679            let report = DriftDetector::Adwin { delta: 0.002 }.detect(&sa, &shifted);
680            assert!(
681                report.drift_detected,
682                "a full-scale mean shift must be detected at scale {scale}"
683            );
684        }
685    }
686
687    /// The KS decision is a p-value, so any `p_threshold` is meaningful — not
688    /// just the three the old lookup table had entries for.
689    #[test]
690    fn ks_p_value_matches_the_textbook_critical_values() {
691        // c(α)·sqrt((n1+n2)/(n1·n2)) is the classical critical value; at
692        // exactly that D the p-value must sit at α.
693        for (alpha, c) in [(0.10, 1.224), (0.05, 1.358), (0.01, 1.628)] {
694            let (n1, n2) = (200usize, 200usize);
695            let critical = c * ((n1 + n2) as f64 / (n1 as f64 * n2 as f64)).sqrt();
696            let p = ks_p_value(critical, n1, n2);
697            assert!(
698                (p - alpha).abs() < 0.01,
699                "D at the α={alpha} critical value should give p≈{alpha}, got {p:.4}"
700            );
701        }
702        // Identical samples are the least surprising outcome there is.
703        assert!((ks_p_value(0.0, 100, 100) - 1.0).abs() < 1e-9);
704        // Disjoint samples are the most.
705        assert!(ks_p_value(1.0, 100, 100) < 1e-9);
706    }
707
708    /// A distribution compared against itself has not drifted, ties or not.
709    ///
710    /// This is the guard behind the note on `quantile_bin_edges`: a heavily
711    /// tied reference produces repeated bin edges, and the reason that is
712    /// harmless is that the resulting zero-width bins are empty on both
713    /// sides. The test is what makes that an assertion rather than an
714    /// argument.
715    #[test]
716    fn psi_does_not_fire_on_a_tied_reference_distribution() {
717        // 80 % of the reference sits at exactly 0.0.
718        let training: Vec<f64> = (0..1000)
719            .map(|i| if i % 5 == 0 { f64::from(i % 50) } else { 0.0 })
720            .collect();
721        let report = DriftDetector::Psi { threshold: 0.2 }.detect(&training, &training.clone());
722        assert!(
723            !report.drift_detected,
724            "a distribution compared against itself cannot have drifted (PSI = {})",
725            report.score
726        );
727        assert!(
728            report.score < 0.01,
729            "PSI against an identical sample must be ~0, got {}",
730            report.score
731        );
732    }
733
734    #[test]
735    fn test_default_detector() {
736        let det = DriftDetector::default();
737        assert!(
738            matches!(det, DriftDetector::Psi { threshold } if (threshold - 0.25).abs() < f64::EPSILON)
739        );
740    }
741
742    /// Quantile-based PSI with highly skewed data should detect
743    /// a distributional shift more reliably than equal-width bins.
744    #[test]
745    fn test_psi_quantile_bins_skewed_data() {
746        // Training: heavily right-skewed (exponential-like)
747        let training: Vec<f64> = (0..1000).map(|i| ((i as f64) / 100.0).exp()).collect();
748
749        // Current: same shape but shifted (multiplied by 5 + offset)
750        let current: Vec<f64> = (0..1000)
751            .map(|i| 5.0 * ((i as f64) / 100.0).exp() + 50.0)
752            .collect();
753
754        let det = DriftDetector::Psi { threshold: 0.25 };
755        let report = det.detect(&training, &current);
756        // The shift should be detected with quantile bins
757        assert!(
758            report.drift_detected,
759            "PSI should detect drift in skewed data, score={}",
760            report.score
761        );
762        assert!(report.score > 0.25);
763    }
764
765    #[test]
766    fn test_quantile_bin_edges_uniform() {
767        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
768        let edges = quantile_bin_edges(&data, 10);
769        assert_eq!(edges.len(), 11);
770        // First edge is 0.0, roughly 10-unit spacing
771        assert!((edges[0] - 0.0).abs() < 1.0);
772        assert!((edges[5] - 50.0).abs() < 2.0);
773    }
774
775    #[test]
776    fn test_bin_counts_by_edges_basic() {
777        let edges = vec![0.0, 5.0, 10.0 + f64::EPSILON];
778        let data = vec![1.0, 2.0, 3.0, 6.0, 7.0, 8.0];
779        let counts = bin_counts_by_edges(&data, &edges);
780        assert_eq!(counts.len(), 2);
781        assert!((counts[0] - 3.0).abs() < f64::EPSILON); // 1,2,3
782        assert!((counts[1] - 3.0).abs() < f64::EPSILON); // 6,7,8
783    }
784
785    #[test]
786    fn drift_monitor_detects_shift() {
787        use std::sync::atomic::{AtomicUsize, Ordering};
788
789        let fired = Arc::new(AtomicUsize::new(0));
790        let fired_clone = fired.clone();
791
792        let monitor = DriftMonitor::new(
793            Duration::from_secs(60),
794            Box::new(move |_measurement, _report| {
795                fired_clone.fetch_add(1, Ordering::SeqCst);
796            }),
797        );
798
799        // Register with normal training data
800        let training: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.01).collect();
801        monitor.register("cpu", DriftDetector::Psi { threshold: 0.25 }, training);
802
803        // Update with shifted distribution
804        let shifted: Vec<f64> = (0..1000).map(|i| 100.0 + (i as f64) * 0.01).collect();
805        monitor.update_current("cpu", shifted);
806
807        let reports = monitor.check_all();
808        assert!(!reports.is_empty());
809        assert!(reports[0].1.drift_detected);
810        assert!(fired.load(Ordering::SeqCst) > 0);
811    }
812
813    #[test]
814    fn callback_can_register_without_deadlock() {
815        // Regression test for H4: callback was invoked under the mutex lock,
816        // which would deadlock if it called register()/update_current().
817        use std::sync::atomic::{AtomicBool, Ordering};
818
819        let lock_free = Arc::new(AtomicBool::new(false));
820        let lock_free_clone = lock_free.clone();
821
822        // We share the models Arc so the callback can try_lock() it.
823        let models = Arc::new(Mutex::new(Vec::<MonitoredModel>::new()));
824        let models_for_callback = Arc::clone(&models);
825
826        let monitor = DriftMonitor {
827            models: Arc::clone(&models),
828            interval: Duration::from_secs(60),
829            callback: Arc::new(Box::new(move |_measurement, _report| {
830                // Try to acquire the lock — if callback is outside the lock,
831                // this will succeed. If still under the lock, it would deadlock
832                // (Mutex is not reentrant).
833                let acquired = models_for_callback.try_lock().is_some();
834                lock_free_clone.store(acquired, Ordering::SeqCst);
835            })),
836            max_snapshot_size: DEFAULT_MAX_SNAPSHOT_SIZE,
837        };
838
839        let training: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.01).collect();
840        monitor.register("cpu", DriftDetector::Psi { threshold: 0.25 }, training);
841
842        let shifted: Vec<f64> = (0..1000).map(|i| 100.0 + (i as f64) * 0.01).collect();
843        monitor.update_current("cpu", shifted);
844
845        let reports = monitor.check_all();
846        assert!(!reports.is_empty());
847        assert!(reports[0].1.drift_detected);
848        assert!(
849            lock_free.load(Ordering::SeqCst),
850            "callback should execute outside lock"
851        );
852    }
853}