1use parking_lot::Mutex;
12use std::sync::Arc;
13use std::time::Duration;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub enum DriftAction {
18 NoAction,
20 RetriggerFit,
22 AlertOnly,
24}
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct ModelDriftReport {
29 pub detector_name: String,
31 pub score: f64,
33 pub drift_detected: bool,
35 pub action: DriftAction,
37}
38
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub enum DriftDetector {
42 Psi {
44 threshold: f64,
46 },
47 KolmogorovSmirnov {
50 p_value: f64,
52 },
53 Adwin {
55 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 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
86fn 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
108fn 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; 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
144fn 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 if let Some(last) = edges.last_mut() {
158 *last += f64::EPSILON;
159 }
160 edges
172}
173
174fn 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 let mut idx = match edges.binary_search_by(|e| e.total_cmp(&v)) {
188 Ok(i) => i, Err(i) => i, };
191 if idx == 0 {
193 counts[0] += 1.0;
194 continue;
195 }
196 if idx >= n_bins {
198 counts[n_bins - 1] += 1.0;
199 continue;
200 }
201 idx -= 1;
206 counts[idx] += 1.0;
207 }
208 counts
209}
210
211fn 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 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
246fn 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
280fn 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 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
326fn 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 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 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
411pub type DriftCallback = Box<dyn Fn(&str, &ModelDriftReport) + Send + Sync>;
415
416pub struct DriftMonitor {
426 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
440const DEFAULT_MAX_SNAPSHOT_SIZE: usize = 10_000;
442
443fn 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 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 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 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 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 pub fn check_all(&self) -> Vec<(String, ModelDriftReport)> {
516 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 }; for (measurement, report) in &reports {
533 if report.drift_detected {
534 (self.callback)(measurement, report);
535 }
536 }
537
538 reports
539 }
540
541 pub fn interval(&self) -> Duration {
543 self.interval
544 }
545}
546
547#[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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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 let training: Vec<f64> = (0..2000).map(|i| i as f64).collect();
639 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, ¤t);
644
645 assert!(report.action != DriftAction::RetriggerFit || report.score >= 0.25);
647 }
648
649 #[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 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 #[test]
690 fn ks_p_value_matches_the_textbook_critical_values() {
691 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 assert!((ks_p_value(0.0, 100, 100) - 1.0).abs() < 1e-9);
704 assert!(ks_p_value(1.0, 100, 100) < 1e-9);
706 }
707
708 #[test]
716 fn psi_does_not_fire_on_a_tied_reference_distribution() {
717 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 #[test]
745 fn test_psi_quantile_bins_skewed_data() {
746 let training: Vec<f64> = (0..1000).map(|i| ((i as f64) / 100.0).exp()).collect();
748
749 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, ¤t);
756 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 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); assert!((counts[1] - 3.0).abs() < f64::EPSILON); }
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 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 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 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 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 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}