use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DriftAction {
NoAction,
RetriggerFit,
AlertOnly,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ModelDriftReport {
pub detector_name: String,
pub score: f64,
pub drift_detected: bool,
pub action: DriftAction,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DriftDetector {
Psi {
threshold: f64,
},
KolmogorovSmirnov {
p_value: f64,
},
Adwin {
delta: f64,
},
}
impl Default for DriftDetector {
fn default() -> Self {
Self::Psi { threshold: 0.25 }
}
}
impl DriftDetector {
pub fn detect(&self, training: &[f64], current: &[f64]) -> ModelDriftReport {
let report = match self {
Self::Psi { threshold } => psi_detect(training, current, *threshold),
Self::KolmogorovSmirnov { p_value } => ks_detect(training, current, *p_value),
Self::Adwin { delta } => adwin_detect(training, current, *delta),
};
if report.drift_detected {
metrics::counter!("chronix_model_drift_detected_total", "method" => report.detector_name.clone()).increment(1);
}
metrics::gauge!("chronix_model_drift_score", "method" => report.detector_name.clone())
.set(report.score);
report
}
}
fn psi_detect(training: &[f64], current: &[f64], threshold: f64) -> ModelDriftReport {
let n_bins = 10;
let psi = compute_psi(training, current, n_bins);
let drift_detected = psi >= threshold;
let action = if psi >= threshold {
DriftAction::RetriggerFit
} else if psi >= threshold * 0.4 {
DriftAction::AlertOnly
} else {
DriftAction::NoAction
};
ModelDriftReport {
detector_name: "PSI".into(),
score: psi,
drift_detected,
action,
}
}
fn compute_psi(training: &[f64], current: &[f64], n_bins: usize) -> f64 {
if training.is_empty() || current.is_empty() || n_bins == 0 {
return 0.0;
}
let mut sorted_train: Vec<f64> = training.to_vec();
sorted_train.sort_by(f64::total_cmp);
let edges = quantile_bin_edges(&sorted_train, n_bins);
let t_counts = bin_counts_by_edges(training, &edges);
let c_counts = bin_counts_by_edges(current, &edges);
let t_total: f64 = t_counts.iter().sum();
let c_total: f64 = c_counts.iter().sum();
let eps = 1e-4;
if t_total == 0.0 || c_total == 0.0 {
return 0.0;
}
let mut psi = 0.0;
for i in 0..t_counts.len() {
let p = (t_counts[i] / t_total).max(eps);
let q = (c_counts[i] / c_total).max(eps);
psi += (q - p) * (q / p).ln();
}
psi.max(0.0)
}
fn quantile_bin_edges(sorted_data: &[f64], n_bins: usize) -> Vec<f64> {
let n = sorted_data.len();
let mut edges = Vec::with_capacity(n_bins + 1);
for i in 0..=n_bins {
let idx = (i * n / n_bins).min(n.saturating_sub(1));
edges.push(sorted_data[idx]);
}
if let Some(last) = edges.last_mut() {
*last += f64::EPSILON;
}
edges
}
fn bin_counts_by_edges(data: &[f64], edges: &[f64]) -> Vec<f64> {
let n_bins = edges.len() - 1;
let mut counts = vec![0.0_f64; n_bins];
for &v in data {
if v.is_nan() {
continue;
}
let mut idx = match edges.binary_search_by(|e| e.total_cmp(&v)) {
Ok(i) => i, Err(i) => i, };
if idx == 0 {
counts[0] += 1.0;
continue;
}
if idx >= n_bins {
counts[n_bins - 1] += 1.0;
continue;
}
idx -= 1;
counts[idx] += 1.0;
}
counts
}
fn ks_detect(training: &[f64], current: &[f64], p_threshold: f64) -> ModelDriftReport {
if training.is_empty() || current.is_empty() {
return ModelDriftReport {
detector_name: "KolmogorovSmirnov".into(),
score: 0.0,
drift_detected: false,
action: DriftAction::NoAction,
};
}
let ks_stat = ks_statistic(training, current);
let drift_detected = ks_p_value(ks_stat, training.len(), current.len()) < p_threshold;
let action = if drift_detected {
DriftAction::RetriggerFit
} else {
DriftAction::NoAction
};
ModelDriftReport {
detector_name: "KolmogorovSmirnov".into(),
score: ks_stat,
drift_detected,
action,
}
}
fn ks_p_value(d: f64, n1: usize, n2: usize) -> f64 {
if n1 == 0 || n2 == 0 || !d.is_finite() {
return 1.0;
}
let n_eff = (n1 as f64 * n2 as f64) / (n1 as f64 + n2 as f64);
let sqrt_n = n_eff.sqrt();
let lambda = d * (sqrt_n + 0.12 + 0.11 / sqrt_n);
if lambda <= 0.0 {
return 1.0;
}
let mut sum = 0.0;
let mut sign = 1.0;
for k in 1..=100 {
let term = (-2.0 * f64::from(k) * f64::from(k) * lambda * lambda).exp();
sum += sign * term;
if term < 1e-12 * sum.abs() || term < f64::MIN_POSITIVE {
break;
}
sign = -sign;
}
(2.0 * sum).clamp(0.0, 1.0)
}
fn ks_statistic(a: &[f64], b: &[f64]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut sa: Vec<f64> = a.to_vec();
sa.sort_by(f64::total_cmp);
let mut sb: Vec<f64> = b.to_vec();
sb.sort_by(f64::total_cmp);
let na = sa.len() as f64;
let nb = sb.len() as f64;
let mut ia = 0usize;
let mut ib = 0usize;
let mut max_diff = 0.0_f64;
while ia < sa.len() && ib < sb.len() {
if sa[ia] <= sb[ib] {
ia += 1;
} else {
ib += 1;
}
let fa = ia as f64 / na;
let fb = ib as f64 / nb;
max_diff = max_diff.max((fa - fb).abs());
}
while ia < sa.len() {
let fa = (ia + 1) as f64 / na;
let fb = ib as f64 / nb;
max_diff = max_diff.max((fa - fb).abs());
ia += 1;
}
while ib < sb.len() {
let fa = ia as f64 / na;
let fb = (ib + 1) as f64 / nb;
max_diff = max_diff.max((fa - fb).abs());
ib += 1;
}
max_diff
}
fn adwin_epsilon_cut(training: &[f64], current: &[f64], delta: f64) -> f64 {
let n0 = training.len() as f64;
let n1 = current.len() as f64;
let n = n0 + n1;
let mean: f64 = (training.iter().sum::<f64>() + current.iter().sum::<f64>()) / n;
let variance: f64 = training
.iter()
.chain(current.iter())
.map(|v| (v - mean).powi(2))
.sum::<f64>()
/ n;
let m = 1.0 / (1.0 / n0 + 1.0 / n1);
let delta_prime = (delta / n).clamp(f64::MIN_POSITIVE, 1.0);
let ln_term = (2.0 / delta_prime).ln();
((2.0 / m) * variance * ln_term).sqrt() + (2.0 / (3.0 * m)) * ln_term
}
fn adwin_detect(training: &[f64], current: &[f64], delta: f64) -> ModelDriftReport {
if training.is_empty() || current.is_empty() {
return ModelDriftReport {
detector_name: "ADWIN".into(),
score: 0.0,
drift_detected: false,
action: DriftAction::NoAction,
};
}
let mean1 = training.iter().sum::<f64>() / training.len() as f64;
let mean2 = current.iter().sum::<f64>() / current.len() as f64;
let epsilon_cut = adwin_epsilon_cut(training, current, delta);
let diff = (mean1 - mean2).abs();
let drift_detected = diff > epsilon_cut;
let action = if drift_detected {
DriftAction::RetriggerFit
} else {
DriftAction::NoAction
};
ModelDriftReport {
detector_name: "ADWIN".into(),
score: diff,
drift_detected,
action,
}
}
pub type DriftCallback = Box<dyn Fn(&str, &ModelDriftReport) + Send + Sync>;
pub struct DriftMonitor {
models: Arc<Mutex<Vec<MonitoredModel>>>,
interval: Duration,
callback: Arc<DriftCallback>,
max_snapshot_size: usize,
}
struct MonitoredModel {
measurement: String,
detector: DriftDetector,
training_snapshot: Vec<f64>,
current_snapshot: Vec<f64>,
}
const DEFAULT_MAX_SNAPSHOT_SIZE: usize = 10_000;
fn cap_snapshot(data: &mut Vec<f64>, max_size: usize) {
if data.len() <= max_size || max_size == 0 {
return;
}
let step = data.len() as f64 / max_size as f64;
let mut sampled = Vec::with_capacity(max_size);
for i in 0..max_size {
let idx = (i as f64 * step) as usize;
sampled.push(data[idx]);
}
*data = sampled;
}
impl DriftMonitor {
pub fn new(interval: Duration, callback: DriftCallback) -> Self {
Self {
models: Arc::new(Mutex::new(Vec::new())),
interval,
callback: Arc::new(callback),
max_snapshot_size: DEFAULT_MAX_SNAPSHOT_SIZE,
}
}
pub fn with_max_snapshot_size(
interval: Duration,
callback: DriftCallback,
max_snapshot_size: usize,
) -> Self {
Self {
models: Arc::new(Mutex::new(Vec::new())),
interval,
callback: Arc::new(callback),
max_snapshot_size,
}
}
pub fn register(
&self,
measurement: &str,
detector: DriftDetector,
training_snapshot: Vec<f64>,
) {
let mut snapshot = training_snapshot;
cap_snapshot(&mut snapshot, self.max_snapshot_size);
let mut models = self.models.lock();
models.push(MonitoredModel {
measurement: measurement.to_string(),
detector,
training_snapshot: snapshot,
current_snapshot: Vec::new(),
});
}
pub fn update_current(&self, measurement: &str, data: Vec<f64>) {
let mut capped = data;
cap_snapshot(&mut capped, self.max_snapshot_size);
let mut models = self.models.lock();
for model in models.iter_mut() {
if model.measurement == measurement {
model.current_snapshot = capped.clone();
}
}
}
pub fn check_all(&self) -> Vec<(String, ModelDriftReport)> {
let reports: Vec<(String, ModelDriftReport)> = {
let models = self.models.lock();
models
.iter()
.filter(|m| !m.current_snapshot.is_empty())
.map(|m| {
let report = m.detector.detect(&m.training_snapshot, &m.current_snapshot);
(m.measurement.clone(), report)
})
.collect()
};
for (measurement, report) in &reports {
if report.drift_detected {
(self.callback)(measurement, report);
}
}
reports
}
pub fn interval(&self) -> Duration {
self.interval
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_psi_no_drift_same_distribution() {
let training: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0).collect();
let current: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0).collect();
let det = DriftDetector::Psi { threshold: 0.25 };
let report = det.detect(&training, ¤t);
assert!(!report.drift_detected);
assert!(report.score < 0.01);
assert_eq!(report.action, DriftAction::NoAction);
}
#[test]
fn test_psi_drift_detected_shifted() {
let training: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0).collect();
let current: Vec<f64> = (0..1000).map(|i| i as f64 / 100.0 + 50.0).collect();
let det = DriftDetector::Psi { threshold: 0.25 };
let report = det.detect(&training, ¤t);
assert!(report.drift_detected);
assert!(report.score > 0.25);
assert_eq!(report.action, DriftAction::RetriggerFit);
}
#[test]
fn test_ks_same_distribution() {
let a: Vec<f64> = (0..500).map(|i| i as f64).collect();
let b: Vec<f64> = (0..500).map(|i| i as f64).collect();
let det = DriftDetector::KolmogorovSmirnov { p_value: 0.05 };
let report = det.detect(&a, &b);
assert!(!report.drift_detected);
}
#[test]
fn test_ks_drift_detected() {
let a: Vec<f64> = (0..500).map(|i| i as f64).collect();
let b: Vec<f64> = (0..500).map(|i| i as f64 + 1000.0).collect();
let det = DriftDetector::KolmogorovSmirnov { p_value: 0.05 };
let report = det.detect(&a, &b);
assert!(report.drift_detected);
assert_eq!(report.action, DriftAction::RetriggerFit);
}
#[test]
fn test_adwin_no_drift() {
let training = vec![5.0; 100];
let current = vec![5.0; 100];
let det = DriftDetector::Adwin { delta: 0.002 };
let report = det.detect(&training, ¤t);
assert!(!report.drift_detected);
}
#[test]
fn test_adwin_drift_detected() {
let training = vec![5.0; 100];
let current = vec![50.0; 100];
let det = DriftDetector::Adwin { delta: 0.002 };
let report = det.detect(&training, ¤t);
assert!(report.drift_detected);
assert_eq!(report.action, DriftAction::RetriggerFit);
}
#[test]
fn test_ks_statistic_identical() {
let a: Vec<f64> = (0..100).map(|i| i as f64).collect();
let stat = ks_statistic(&a, &a);
assert!(stat < 0.02);
}
#[test]
fn test_ks_statistic_disjoint() {
let a: Vec<f64> = (0..100).map(|i| i as f64).collect();
let b: Vec<f64> = (200..300).map(|i| i as f64).collect();
let stat = ks_statistic(&a, &b);
assert!((stat - 1.0).abs() < 0.02);
}
#[test]
fn test_psi_alert_only() {
let training: Vec<f64> = (0..2000).map(|i| i as f64).collect();
let current: Vec<f64> = (0..2000).map(|i| i as f64 + 200.0).collect();
let det = DriftDetector::Psi { threshold: 0.25 };
let report = det.detect(&training, ¤t);
assert!(report.action != DriftAction::RetriggerFit || report.score >= 0.25);
}
#[test]
fn adwin_is_scale_invariant() {
let mut seed = 7u64;
let mut noise = || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
((seed >> 33) as f64 / f64::from(u32::MAX)) - 0.5
};
let base: Vec<f64> = (0..200).map(|_| 0.5 + noise() * 0.2).collect();
let (a, b) = base.split_at(100);
for scale in [1.0, 100.0, 10_000.0] {
let sa: Vec<f64> = a.iter().map(|v| v * scale).collect();
let sb: Vec<f64> = b.iter().map(|v| v * scale).collect();
let report = DriftDetector::Adwin { delta: 0.002 }.detect(&sa, &sb);
assert!(
!report.drift_detected,
"no drift at scale {scale}: two halves of one distribution"
);
}
for scale in [1.0, 100.0, 10_000.0] {
let sa: Vec<f64> = a.iter().map(|v| v * scale).collect();
let shifted: Vec<f64> = b.iter().map(|v| (v + 1.0) * scale).collect();
let report = DriftDetector::Adwin { delta: 0.002 }.detect(&sa, &shifted);
assert!(
report.drift_detected,
"a full-scale mean shift must be detected at scale {scale}"
);
}
}
#[test]
fn ks_p_value_matches_the_textbook_critical_values() {
for (alpha, c) in [(0.10, 1.224), (0.05, 1.358), (0.01, 1.628)] {
let (n1, n2) = (200usize, 200usize);
let critical = c * ((n1 + n2) as f64 / (n1 as f64 * n2 as f64)).sqrt();
let p = ks_p_value(critical, n1, n2);
assert!(
(p - alpha).abs() < 0.01,
"D at the α={alpha} critical value should give p≈{alpha}, got {p:.4}"
);
}
assert!((ks_p_value(0.0, 100, 100) - 1.0).abs() < 1e-9);
assert!(ks_p_value(1.0, 100, 100) < 1e-9);
}
#[test]
fn psi_does_not_fire_on_a_tied_reference_distribution() {
let training: Vec<f64> = (0..1000)
.map(|i| if i % 5 == 0 { f64::from(i % 50) } else { 0.0 })
.collect();
let report = DriftDetector::Psi { threshold: 0.2 }.detect(&training, &training.clone());
assert!(
!report.drift_detected,
"a distribution compared against itself cannot have drifted (PSI = {})",
report.score
);
assert!(
report.score < 0.01,
"PSI against an identical sample must be ~0, got {}",
report.score
);
}
#[test]
fn test_default_detector() {
let det = DriftDetector::default();
assert!(
matches!(det, DriftDetector::Psi { threshold } if (threshold - 0.25).abs() < f64::EPSILON)
);
}
#[test]
fn test_psi_quantile_bins_skewed_data() {
let training: Vec<f64> = (0..1000).map(|i| ((i as f64) / 100.0).exp()).collect();
let current: Vec<f64> = (0..1000)
.map(|i| 5.0 * ((i as f64) / 100.0).exp() + 50.0)
.collect();
let det = DriftDetector::Psi { threshold: 0.25 };
let report = det.detect(&training, ¤t);
assert!(
report.drift_detected,
"PSI should detect drift in skewed data, score={}",
report.score
);
assert!(report.score > 0.25);
}
#[test]
fn test_quantile_bin_edges_uniform() {
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let edges = quantile_bin_edges(&data, 10);
assert_eq!(edges.len(), 11);
assert!((edges[0] - 0.0).abs() < 1.0);
assert!((edges[5] - 50.0).abs() < 2.0);
}
#[test]
fn test_bin_counts_by_edges_basic() {
let edges = vec![0.0, 5.0, 10.0 + f64::EPSILON];
let data = vec![1.0, 2.0, 3.0, 6.0, 7.0, 8.0];
let counts = bin_counts_by_edges(&data, &edges);
assert_eq!(counts.len(), 2);
assert!((counts[0] - 3.0).abs() < f64::EPSILON); assert!((counts[1] - 3.0).abs() < f64::EPSILON); }
#[test]
fn drift_monitor_detects_shift() {
use std::sync::atomic::{AtomicUsize, Ordering};
let fired = Arc::new(AtomicUsize::new(0));
let fired_clone = fired.clone();
let monitor = DriftMonitor::new(
Duration::from_secs(60),
Box::new(move |_measurement, _report| {
fired_clone.fetch_add(1, Ordering::SeqCst);
}),
);
let training: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.01).collect();
monitor.register("cpu", DriftDetector::Psi { threshold: 0.25 }, training);
let shifted: Vec<f64> = (0..1000).map(|i| 100.0 + (i as f64) * 0.01).collect();
monitor.update_current("cpu", shifted);
let reports = monitor.check_all();
assert!(!reports.is_empty());
assert!(reports[0].1.drift_detected);
assert!(fired.load(Ordering::SeqCst) > 0);
}
#[test]
fn callback_can_register_without_deadlock() {
use std::sync::atomic::{AtomicBool, Ordering};
let lock_free = Arc::new(AtomicBool::new(false));
let lock_free_clone = lock_free.clone();
let models = Arc::new(Mutex::new(Vec::<MonitoredModel>::new()));
let models_for_callback = Arc::clone(&models);
let monitor = DriftMonitor {
models: Arc::clone(&models),
interval: Duration::from_secs(60),
callback: Arc::new(Box::new(move |_measurement, _report| {
let acquired = models_for_callback.try_lock().is_some();
lock_free_clone.store(acquired, Ordering::SeqCst);
})),
max_snapshot_size: DEFAULT_MAX_SNAPSHOT_SIZE,
};
let training: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.01).collect();
monitor.register("cpu", DriftDetector::Psi { threshold: 0.25 }, training);
let shifted: Vec<f64> = (0..1000).map(|i| 100.0 + (i as f64) * 0.01).collect();
monitor.update_current("cpu", shifted);
let reports = monitor.check_all();
assert!(!reports.is_empty());
assert!(reports[0].1.drift_detected);
assert!(
lock_free.load(Ordering::SeqCst),
"callback should execute outside lock"
);
}
}