#[derive(Debug, Clone)]
pub struct AdwinDetector {
pub window: Vec<f64>,
pub delta: f64,
pub min_samples: usize,
pub drift_detected: bool,
pub n_drift_detections: usize,
}
#[must_use]
pub fn adwin_new(delta: f64, min_samples: usize) -> AdwinDetector {
AdwinDetector {
window: Vec::new(),
delta,
min_samples,
drift_detected: false,
n_drift_detections: 0,
}
}
pub fn adwin_add(detector: &mut AdwinDetector, value: f64) -> bool {
detector.window.push(value);
detector.drift_detected = false;
let m = detector.window.len();
if m < detector.min_samples {
return false;
}
let mut prefix_sum = vec![0.0_f64; m + 1];
for i in 0..m {
prefix_sum[i + 1] = prefix_sum[i] + detector.window[i];
}
let total_sum = prefix_sum[m];
let ln_term = (4.0 * m as f64 / detector.delta).ln();
let mut drift_found = false;
let mut split_point = 0_usize;
#[allow(clippy::needless_range_loop)]
'outer: for k in 1..m {
let n0 = k as f64;
let n1 = (m - k) as f64;
let mu_left = prefix_sum[k] / n0;
let mu_right = (total_sum - prefix_sum[k]) / n1;
let eps_cut = ((0.5 * (1.0 / n0 + 1.0 / n1) * ln_term).sqrt()).max(0.0);
if (mu_left - mu_right).abs() >= eps_cut {
drift_found = true;
split_point = k;
break 'outer;
}
}
if drift_found {
detector.window.drain(..split_point);
detector.drift_detected = true;
detector.n_drift_detections += 1;
}
drift_found
}
#[must_use]
pub fn adwin_mean(detector: &AdwinDetector) -> f64 {
if detector.window.is_empty() {
return 0.0;
}
let sum: f64 = detector.window.iter().sum();
sum / detector.window.len() as f64
}
#[must_use]
pub fn adwin_window_size(detector: &AdwinDetector) -> usize {
detector.window.len()
}
#[derive(Debug, Clone)]
pub struct PageHinkleyDetector {
pub running_mean: f64,
pub n: usize,
pub sum: f64,
pub min_sum: f64,
pub lambda: f64,
pub h: f64,
pub alpha: f64,
pub drift_detected: bool,
}
#[must_use]
pub fn ph_detector_new(lambda: f64, h: f64, alpha: f64) -> PageHinkleyDetector {
PageHinkleyDetector {
running_mean: 0.0,
n: 0,
sum: 0.0,
min_sum: 0.0,
lambda,
h,
alpha,
drift_detected: false,
}
}
pub fn ph_add(detector: &mut PageHinkleyDetector, value: f64) -> bool {
detector.n += 1;
detector.running_mean = detector.alpha * detector.running_mean + (1.0 - detector.alpha) * value;
let deviation = value - detector.running_mean;
let m_t = deviation - detector.lambda;
detector.sum = (detector.sum + m_t).max(0.0);
let m_neg_t = -deviation - detector.lambda;
detector.min_sum = (detector.min_sum + m_neg_t).max(0.0);
let drift = detector.sum > detector.h || detector.min_sum > detector.h;
detector.drift_detected = drift;
drift
}
pub fn ph_reset(detector: &mut PageHinkleyDetector) {
detector.running_mean = 0.0;
detector.n = 0;
detector.sum = 0.0;
detector.min_sum = 0.0;
detector.drift_detected = false;
}
#[derive(Debug, Clone)]
pub struct CusumDetector {
pub mu0: f64,
pub mu1: f64,
pub h: f64,
pub s_pos: f64,
pub s_neg: f64,
pub sigma: f64,
pub n: usize,
pub drift_detected: bool,
}
#[must_use]
pub fn cusum_new(mu0: f64, mu1: f64, sigma: f64, h: f64) -> CusumDetector {
CusumDetector {
mu0,
mu1,
h,
s_pos: 0.0,
s_neg: 0.0,
sigma,
n: 0,
drift_detected: false,
}
}
pub fn cusum_add(detector: &mut CusumDetector, value: f64) -> bool {
detector.n += 1;
let k = (detector.mu1 - detector.mu0).abs() / 2.0;
let deviation = value - detector.mu0;
detector.s_pos = (detector.s_pos + deviation - k).max(0.0);
detector.s_neg = (detector.s_neg - deviation - k).max(0.0);
let drift = detector.s_pos > detector.h || detector.s_neg > detector.h;
detector.drift_detected = drift;
drift
}
pub fn cusum_reset(detector: &mut CusumDetector) {
detector.s_pos = 0.0;
detector.s_neg = 0.0;
detector.n = 0;
detector.drift_detected = false;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DdmStatus {
Normal,
Warning,
Drift,
}
#[derive(Debug, Clone)]
pub struct DdmDetector {
pub n: usize,
pub p_hat: f64,
pub p_min: f64,
pub s_min: f64,
pub warning: bool,
pub drift_detected: bool,
pub n_drift_detections: usize,
}
#[must_use]
pub fn ddm_new() -> DdmDetector {
DdmDetector {
n: 0,
p_hat: 0.0,
p_min: f64::MAX,
s_min: f64::MAX,
warning: false,
drift_detected: false,
n_drift_detections: 0,
}
}
pub fn ddm_add(detector: &mut DdmDetector, error: f64) -> DdmStatus {
detector.n += 1;
let t = detector.n as f64;
detector.p_hat += (error - detector.p_hat) / t;
let p = detector.p_hat.clamp(0.0, 1.0);
let s = (p * (1.0 - p) / t).sqrt();
detector.warning = false;
detector.drift_detected = false;
if detector.n < 30 {
if p + s < detector.p_min + detector.s_min {
detector.p_min = p;
detector.s_min = s;
}
return DdmStatus::Normal;
}
if detector.p_min == f64::MAX || p + s < detector.p_min + detector.s_min {
detector.p_min = p;
detector.s_min = s;
}
let level = p + s;
let warn_thresh = detector.p_min + 2.0 * detector.s_min;
let drift_thresh = detector.p_min + 3.0 * detector.s_min;
if level >= drift_thresh {
detector.drift_detected = true;
detector.n_drift_detections += 1;
detector.p_min = f64::MAX;
detector.s_min = f64::MAX;
DdmStatus::Drift
} else if level >= warn_thresh {
detector.warning = true;
DdmStatus::Warning
} else {
DdmStatus::Normal
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::LcgRng;
#[test]
fn adwin_no_drift_stable() {
let mut rng = LcgRng::new(0xABCD_1234);
let mut det = adwin_new(0.002, 20);
let mut any_drift = false;
for _ in 0..100 {
let v = rng.next_normal() as f64 * 0.1;
if adwin_add(&mut det, v) {
any_drift = true;
}
}
assert!(
!any_drift,
"ADWIN should not detect drift on stable N(0,0.1) stream (n_det={})",
det.n_drift_detections
);
}
#[test]
fn adwin_drift_detected() {
let mut rng = LcgRng::new(0xDEAD_BEEF);
let mut det = adwin_new(0.002, 10);
for _ in 0..100 {
let v = rng.next_normal() as f64 * 0.1;
adwin_add(&mut det, v);
}
let mut detected = false;
for _ in 0..50 {
let v = 5.0 + rng.next_normal() as f64 * 0.1;
if adwin_add(&mut det, v) {
detected = true;
break;
}
}
assert!(
detected,
"ADWIN must detect mean shift 0→5 within 50 points"
);
}
#[test]
fn adwin_window_shrinks_after_drift() {
let mut rng = LcgRng::new(0xBEEF_1337);
let mut det = adwin_new(0.002, 10);
for _ in 0..100 {
let v = rng.next_normal() as f64 * 0.1;
adwin_add(&mut det, v);
}
let size_before_drift = adwin_window_size(&det);
assert!(
size_before_drift >= 10,
"window must have grown during stable phase"
);
let mut size_at_drift = None;
for _ in 0..100 {
let v = 10.0 + rng.next_normal() as f64 * 0.1;
let drifted = adwin_add(&mut det, v);
if drifted && size_at_drift.is_none() {
size_at_drift = Some(adwin_window_size(&det));
}
}
let size_at_drift =
size_at_drift.expect("ADWIN must detect drift on 0→10 mean shift within 100 points");
assert!(
size_at_drift < size_before_drift,
"window must shrink at drift event: before={size_before_drift} at_drift={size_at_drift}"
);
}
#[test]
fn ph_no_drift_stable() {
let mut rng = LcgRng::new(0x1111_AAAA);
let mut det = ph_detector_new(0.005, 50.0, 0.9999);
let mut any_alarm = false;
for _ in 0..200 {
let v = rng.next_normal() as f64 * 0.05;
if ph_add(&mut det, v) {
any_alarm = true;
}
}
assert!(!any_alarm, "PH should not alarm on stable N(0,0.05) stream");
}
#[test]
fn ph_drift_detected() {
let mut rng = LcgRng::new(0x2222_BBBB);
let mut det = ph_detector_new(0.0, 2.0, 0.999);
for _ in 0..50 {
let v = rng.next_normal() as f64 * 0.1;
ph_add(&mut det, v);
}
let mut detected = false;
for i in 1..=100_usize {
let v = i as f64 * 0.5;
if ph_add(&mut det, v) {
detected = true;
break;
}
}
assert!(detected, "PH must detect gradual upward shift");
}
#[test]
fn cusum_no_drift_stable() {
let mut rng = LcgRng::new(0x3333_CCCC);
let mut det = cusum_new(0.0, 1.0, 1.0, 5.0);
let mut any_alarm = false;
for _ in 0..300 {
let v = rng.next_normal() as f64 * 0.2;
if cusum_add(&mut det, v) {
any_alarm = true;
}
}
assert!(
!any_alarm,
"CUSUM should not alarm on tightly in-control stream; s_pos={} s_neg={}",
det.s_pos, det.s_neg
);
}
#[test]
fn cusum_shift_detected() {
let mut rng = LcgRng::new(0x4444_DDDD);
let mut det = cusum_new(0.0, 2.0, 1.0, 5.0);
for _ in 0..50 {
let v = rng.next_normal() as f64 * 0.3;
cusum_add(&mut det, v);
}
let mut detected = false;
for _ in 0..100 {
let v = 3.0 + rng.next_normal() as f64 * 0.3;
if cusum_add(&mut det, v) {
detected = true;
break;
}
}
assert!(detected, "CUSUM must detect upward mean shift");
}
#[test]
fn cusum_two_sided() {
let mut rng = LcgRng::new(0x5555_EEEE);
let mut det = cusum_new(5.0, 3.0, 1.0, 5.0);
for _ in 0..50 {
let v = 5.0 + rng.next_normal() as f64 * 0.3;
cusum_add(&mut det, v);
}
let mut detected = false;
for _ in 0..100 {
let v = 0.0 + rng.next_normal() as f64 * 0.3;
if cusum_add(&mut det, v) {
detected = true;
break;
}
}
assert!(
detected,
"CUSUM lower arm must detect downward shift; s_neg={}",
det.s_neg
);
}
#[test]
fn ddm_stable_low_error() {
let mut det = ddm_new();
let mut last_status = DdmStatus::Normal;
for i in 0..500_usize {
let error = if i % 20 == 0 { 1.0 } else { 0.0 };
last_status = ddm_add(&mut det, error);
}
assert_ne!(
last_status,
DdmStatus::Drift,
"DDM must not signal drift on stable 5% error stream"
);
assert_eq!(det.n_drift_detections, 0);
}
#[test]
fn ddm_increasing_error_drift() {
let mut det = ddm_new();
for i in 0..200_usize {
let error = if i % 20 == 0 { 1.0 } else { 0.0 };
ddm_add(&mut det, error);
}
let mut detected = false;
for _ in 0..200_usize {
let status = ddm_add(&mut det, 1.0);
if status == DdmStatus::Drift {
detected = true;
break;
}
}
assert!(
detected,
"DDM must detect drift when error rate jumps to 100%; detections={}",
det.n_drift_detections
);
}
#[test]
fn cusum_reset_clears_state() {
let mut det = cusum_new(0.0, 2.0, 1.0, 5.0);
for _ in 0..100 {
cusum_add(&mut det, 5.0);
}
assert!(det.drift_detected, "should have alarmed before reset");
cusum_reset(&mut det);
assert_eq!(det.s_pos, 0.0);
assert_eq!(det.s_neg, 0.0);
assert!(!det.drift_detected);
assert_eq!(det.n, 0);
}
#[test]
fn ph_reset_clears_state() {
let mut det = ph_detector_new(0.0, 1.0, 0.999);
for _ in 0..100 {
ph_add(&mut det, 100.0); }
assert!(det.drift_detected);
ph_reset(&mut det);
assert_eq!(det.sum, 0.0);
assert_eq!(det.min_sum, 0.0);
assert_eq!(det.n, 0);
assert!(!det.drift_detected);
}
#[test]
fn adwin_mean_correct() {
let mut det = adwin_new(0.002, 5);
for v in [1.0_f64, 2.0, 3.0, 4.0, 5.0] {
adwin_add(&mut det, v);
}
let m = adwin_mean(&det);
assert!(m.is_finite(), "mean should be finite, got {m}");
assert!(m > 0.0 && m <= 5.0, "mean {m} out of expected range [0,5]");
}
#[test]
fn ddm_warning_before_drift() {
let mut det = ddm_new();
for i in 0..200_usize {
let e = if i % 20 == 0 { 1.0 } else { 0.0 };
ddm_add(&mut det, e);
}
let mut saw_warning = false;
let mut saw_drift = false;
for i in 0..300_usize {
let error = if i % 3 == 0 { 1.0 } else { 0.0 };
let status = ddm_add(&mut det, error);
if status == DdmStatus::Warning {
saw_warning = true;
}
if status == DdmStatus::Drift {
saw_drift = true;
break;
}
}
assert!(
saw_drift || saw_warning,
"DDM must issue at least Warning on rising error rate"
);
}
}