Skip to main content

rill_ml/drift/
kswin.rs

1//! KSWIN (Kolmogorov-Smirnov Windowing) drift detector.
2//!
3//! KSWIN maintains two fixed-size windows — a reference window (older data)
4//! and a current window (newer data) — and periodically runs a two-sample
5//! Kolmogorov-Smirnov test to detect distribution changes.
6//!
7//! ## Algorithm
8//!
9//! 1. Each new observation is appended to the current window.
10//! 2. When the current window fills up, it becomes the new reference window
11//!    (the old reference is discarded) and a fresh current window starts.
12//! 3. Whenever both windows are full and at least `check_interval` samples
13//!    have passed since the last check, the two-sample KS statistic `D` is
14//!    computed.
15//! 4. The p-value is derived via the Marsaglia-Tsang-Wang (2003) algorithm:
16//!    `λ = (√(n_eff) + 0.12 + 0.11/√(n_eff)) · D` with
17//!    `n_eff = n1·n2/(n1+n2)`, then
18//!    `Q_KS(λ) = 2 · Σ_{k=1}^{∞} (-1)^(k-1) · exp(-2·k²·λ²)`.
19//!    The p-value equals `Q_KS(λ)`.
20//! 5. If `p-value < alpha`, drift is reported and the current window becomes
21//!    the new reference, so the new distribution serves as the baseline.
22//!
23//! ## Space complexity
24//!
25//! `O(2 * window_size)` — two fixed-size windows are stored. The KS test
26//! itself uses `O(window_size)` scratch space for sorting.
27
28use crate::drift::detector::{DriftDetector, DriftLevel};
29use crate::error::{RillError, checked_increment, ensure_finite};
30use crate::persistence::ValidateState;
31
32/// Portable KSWIN state schema version.
33pub const KSWIN_PORTABLE_STATE_VERSION: u32 = 1;
34
35/// Configuration for [`Kswin`].
36#[derive(Debug, Clone)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[non_exhaustive]
39pub struct KswinConfig {
40    /// Significance level for the KS test. Must be in `(0, 1)`. Smaller
41    /// values reduce false positives. Defaults to `0.005`.
42    pub alpha: f64,
43
44    /// Size of each window (reference and current). Must be greater than
45    /// zero. Larger values improve sensitivity but increase memory and
46    /// computation. Defaults to `100`.
47    pub window_size: usize,
48
49    /// Minimum number of samples between two consecutive KS checks. Must
50    /// be greater than zero. The actual check interval is the maximum of
51    /// this value and `window_size` (since both windows must be full).
52    /// Defaults to `100`.
53    pub check_interval: usize,
54}
55
56/// Versioned, portable KSWIN state.
57///
58/// Window contents are retained because they are required for exact detector
59/// continuity. Both vectors are bounded by `window_size`.
60#[derive(Debug, Clone, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
63pub struct KswinPortableStateV1 {
64    /// Portable schema version; always `1`.
65    pub version: u32,
66    /// Significance level from the originating configuration.
67    pub alpha: f64,
68    /// Per-window capacity from the originating configuration.
69    pub window_size: usize,
70    /// Check interval from the originating configuration.
71    pub check_interval: usize,
72    /// Older reference window.
73    pub reference_window: Vec<f64>,
74    /// Newer current window.
75    pub current_window: Vec<f64>,
76    /// Total observations incorporated.
77    pub samples: u64,
78    /// Sample counter at the most recent KS test.
79    pub last_check_sample: u64,
80    /// Most recent KS p-value.
81    pub last_pvalue: f64,
82    /// Most recent KS statistic.
83    pub last_statistic: f64,
84    /// Last reported detector level.
85    pub current_level: DriftLevel,
86}
87
88impl ValidateState for KswinPortableStateV1 {
89    fn validate_state(&self) -> Result<(), RillError> {
90        if self.version != KSWIN_PORTABLE_STATE_VERSION {
91            return Err(RillError::IncompatibleStateVersion {
92                expected: KSWIN_PORTABLE_STATE_VERSION,
93                actual: self.version,
94            });
95        }
96        Kswin::new(KswinConfig {
97            alpha: self.alpha,
98            window_size: self.window_size,
99            check_interval: self.check_interval,
100        })?;
101        if self.reference_window.len() > self.window_size
102            || self.current_window.len() > self.window_size
103        {
104            return Err(RillError::InvalidState(
105                "KSWIN portable window exceeds window_size".to_owned(),
106            ));
107        }
108        let retained = self
109            .reference_window
110            .len()
111            .checked_add(self.current_window.len())
112            .ok_or_else(|| RillError::InvalidState("KSWIN retained length overflow".to_owned()))?;
113        if self.samples < retained as u64 || self.last_check_sample > self.samples {
114            return Err(RillError::InvalidState(
115                "KSWIN portable counters are inconsistent".to_owned(),
116            ));
117        }
118        for &value in self.reference_window.iter().chain(&self.current_window) {
119            ensure_finite("portable KSWIN window value", value)?;
120        }
121        ensure_finite("portable KSWIN p-value", self.last_pvalue)?;
122        ensure_finite("portable KSWIN statistic", self.last_statistic)?;
123        if !(0.0..=1.0).contains(&self.last_pvalue) || !(0.0..=1.0).contains(&self.last_statistic) {
124            return Err(RillError::InvalidState(
125                "KSWIN statistic and p-value must be in [0, 1]".to_owned(),
126            ));
127        }
128        if self.current_level == DriftLevel::Warning {
129            return Err(RillError::InvalidState(
130                "KSWIN does not emit warning levels".to_owned(),
131            ));
132        }
133        if self.current_level == DriftLevel::Drift
134            && (!self.current_window.is_empty() || self.reference_window.len() != self.window_size)
135        {
136            return Err(RillError::InvalidState(
137                "KSWIN drift state must have a full reference and empty current window".to_owned(),
138            ));
139        }
140        Ok(())
141    }
142}
143
144impl Default for KswinConfig {
145    fn default() -> Self {
146        Self {
147            alpha: 0.005,
148            window_size: 100,
149            check_interval: 100,
150        }
151    }
152}
153
154/// KSWIN (Kolmogorov-Smirnov Windowing) drift detector.
155///
156/// Detects distribution changes by comparing two fixed-size windows with a
157/// two-sample KS test. Unlike mean-based detectors (Page-Hinkley, ADWIN),
158/// KSWIN is sensitive to distribution shape changes (variance, skewness,
159/// multimodality), not just mean shifts.
160///
161/// The KS test p-value is computed via the Marsaglia-Tsang-Wang algorithm;
162/// no external statistics crate is required.
163///
164/// # Examples
165///
166/// ```
167/// use rill_ml::drift::{DriftDetector, DriftLevel, Kswin, KswinConfig};
168///
169/// let mut kswin_config = KswinConfig::default();
170/// kswin_config.alpha = 0.01;
171/// kswin_config.window_size = 50;
172/// kswin_config.check_interval = 50;
173/// let mut kswin = Kswin::new(kswin_config).unwrap();
174///
175/// // Stable stream around 0.
176/// for _ in 0..100 {
177///     kswin.update(0.0).unwrap();
178/// }
179/// assert_eq!(kswin.level(), DriftLevel::None);
180///
181/// // Distribution shifts to mean 5.
182/// for _ in 0..100 {
183///     kswin.update(5.0).unwrap();
184/// }
185/// // KSWIN should detect the distribution change.
186/// assert!(kswin.samples_seen() > 100);
187/// ```
188#[derive(Debug, Clone)]
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190pub struct Kswin {
191    config: KswinConfig,
192    reference_window: Vec<f64>,
193    current_window: Vec<f64>,
194    samples: u64,
195    last_check_sample: u64,
196    last_pvalue: f64,
197    last_statistic: f64,
198    current_level: DriftLevel,
199}
200
201impl Kswin {
202    /// Create a new KSWIN detector with the given configuration.
203    ///
204    /// Returns an error if:
205    /// - `alpha` is not in `(0, 1)`.
206    /// - `window_size` is zero.
207    /// - `check_interval` is zero.
208    pub fn new(config: KswinConfig) -> Result<Self, RillError> {
209        ensure_finite("alpha", config.alpha)?;
210        if config.alpha <= 0.0 || config.alpha >= 1.0 {
211            return Err(RillError::InvalidSignificanceLevel(config.alpha));
212        }
213        if config.window_size == 0 {
214            return Err(RillError::InvalidCapacity(config.window_size));
215        }
216        if config.check_interval == 0 {
217            return Err(RillError::InvalidCapacity(config.check_interval));
218        }
219        Ok(Self {
220            config,
221            reference_window: Vec::new(),
222            current_window: Vec::new(),
223            samples: 0,
224            last_check_sample: 0,
225            last_pvalue: 1.0,
226            last_statistic: 0.0,
227            current_level: DriftLevel::None,
228        })
229    }
230
231    /// The last computed KS statistic `D` (max CDF difference).
232    pub const fn last_statistic(&self) -> f64 {
233        self.last_statistic
234    }
235
236    /// The last computed p-value from the KS test.
237    pub const fn last_pvalue(&self) -> f64 {
238        self.last_pvalue
239    }
240
241    /// The number of values currently in the reference window.
242    pub fn reference_window_len(&self) -> usize {
243        self.reference_window.len()
244    }
245
246    /// The number of values currently in the current window.
247    pub fn current_window_len(&self) -> usize {
248        self.current_window.len()
249    }
250
251    /// The configuration of this detector.
252    pub const fn config(&self) -> &KswinConfig {
253        &self.config
254    }
255
256    /// Export the stable portable state.
257    pub fn export_state_v1(&self) -> KswinPortableStateV1 {
258        KswinPortableStateV1 {
259            version: KSWIN_PORTABLE_STATE_VERSION,
260            alpha: self.config.alpha,
261            window_size: self.config.window_size,
262            check_interval: self.config.check_interval,
263            reference_window: self.reference_window.clone(),
264            current_window: self.current_window.clone(),
265            samples: self.samples,
266            last_check_sample: self.last_check_sample,
267            last_pvalue: self.last_pvalue,
268            last_statistic: self.last_statistic,
269            current_level: self.current_level,
270        }
271    }
272
273    /// Restore from a validated portable state with exact config matching.
274    pub fn restore_state_v1(
275        config: KswinConfig,
276        state: KswinPortableStateV1,
277    ) -> Result<Self, RillError> {
278        state.validate_state()?;
279        if config.alpha != state.alpha
280            || config.window_size != state.window_size
281            || config.check_interval != state.check_interval
282        {
283            return Err(RillError::InvalidState(
284                "KSWIN portable state configuration mismatch".to_owned(),
285            ));
286        }
287        Kswin::new(config.clone())?;
288        Ok(Self {
289            config,
290            reference_window: state.reference_window,
291            current_window: state.current_window,
292            samples: state.samples,
293            last_check_sample: state.last_check_sample,
294            last_pvalue: state.last_pvalue,
295            last_statistic: state.last_statistic,
296            current_level: state.current_level,
297        })
298    }
299}
300
301impl Default for Kswin {
302    fn default() -> Self {
303        Self::new(KswinConfig::default()).expect("default config is valid")
304    }
305}
306
307impl DriftDetector for Kswin {
308    fn update(&mut self, value: f64) -> Result<DriftLevel, RillError> {
309        ensure_finite("value", value)?;
310        self.samples = checked_increment(self.samples, "samples")?;
311        self.current_level = DriftLevel::None;
312
313        // If the current window is full, rotate: current becomes the new
314        // reference (old reference is dropped), and a fresh current starts.
315        if self.current_window.len() >= self.config.window_size {
316            std::mem::swap(&mut self.reference_window, &mut self.current_window);
317            self.current_window.clear();
318        }
319
320        self.current_window.push(value);
321
322        // Check for drift only when both windows are full and enough samples
323        // have passed since the last check.
324        let both_full = self.reference_window.len() >= self.config.window_size
325            && self.current_window.len() >= self.config.window_size;
326        let interval_ok =
327            self.samples - self.last_check_sample >= self.config.check_interval as u64;
328
329        if both_full && interval_ok {
330            let d = ks_statistic(&self.reference_window, &self.current_window);
331            let p = ks_pvalue(d, self.reference_window.len(), self.current_window.len());
332            self.last_statistic = d;
333            self.last_pvalue = p;
334            self.last_check_sample = self.samples;
335
336            if p < self.config.alpha {
337                self.current_level = DriftLevel::Drift;
338                // Rotate: the current window (new distribution) becomes the
339                // reference, and a fresh current window starts.
340                std::mem::swap(&mut self.reference_window, &mut self.current_window);
341                self.current_window.clear();
342            }
343        }
344
345        Ok(self.current_level)
346    }
347
348    fn detected(&self) -> bool {
349        self.current_level == DriftLevel::Drift
350    }
351
352    fn warning(&self) -> bool {
353        self.current_level == DriftLevel::Warning
354    }
355
356    fn level(&self) -> DriftLevel {
357        self.current_level
358    }
359
360    fn samples_seen(&self) -> u64 {
361        self.samples
362    }
363
364    fn reset(&mut self) {
365        self.reference_window.clear();
366        self.current_window.clear();
367        self.samples = 0;
368        self.last_check_sample = 0;
369        self.last_pvalue = 1.0;
370        self.last_statistic = 0.0;
371        self.current_level = DriftLevel::None;
372    }
373
374    fn last_value(&self) -> f64 {
375        self.last_pvalue
376    }
377}
378
379// ---------------------------------------------------------------------------
380// KS test implementation (Marsaglia-Tsang-Wang 2003)
381// ---------------------------------------------------------------------------
382
383/// Compute the two-sample Kolmogorov-Smirnov statistic `D = max|F_a(x) - F_b(x)|`.
384///
385/// Both slices are sorted internally. Returns `0.0` if either slice is empty.
386pub(crate) fn ks_statistic(a: &[f64], b: &[f64]) -> f64 {
387    if a.is_empty() || b.is_empty() {
388        return 0.0;
389    }
390    let mut a_sorted = a.to_vec();
391    let mut b_sorted = b.to_vec();
392    a_sorted.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
393    b_sorted.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
394
395    let n1 = a_sorted.len() as f64;
396    let n2 = b_sorted.len() as f64;
397
398    let mut i = 0usize;
399    let mut j = 0usize;
400    let mut max_d = 0.0_f64;
401
402    while i < a_sorted.len() && j < b_sorted.len() {
403        if a_sorted[i] < b_sorted[j] {
404            i += 1;
405        } else if a_sorted[i] > b_sorted[j] {
406            j += 1;
407        } else {
408            // Equal values: advance both indices simultaneously to avoid
409            // creating an artificial CDF gap.
410            i += 1;
411            j += 1;
412        }
413        let cdf_a = i as f64 / n1;
414        let cdf_b = j as f64 / n2;
415        let d = (cdf_a - cdf_b).abs();
416        if d > max_d {
417            max_d = d;
418        }
419    }
420
421    max_d
422}
423
424/// Compute the survival function `Q_KS(λ) = P(D > λ)` of the Kolmogorov
425/// distribution using the Marsaglia-Tsang-Wang (2003) series.
426///
427/// This equals the p-value of the two-sample KS test for a given `λ`.
428///
429/// - `λ = 0` → returns `1.0` (no evidence against the null hypothesis).
430/// - `λ → ∞` → returns `0.0` (strong evidence to reject the null).
431pub(crate) fn ks_survival(lambda: f64) -> f64 {
432    if lambda <= 0.0 {
433        return 1.0;
434    }
435    let a2 = -2.0 * lambda * lambda;
436    let mut sum = 0.0_f64;
437    let mut fac = 2.0_f64; // +2 for k=1, flips sign each iteration
438    for k in 1..=100u32 {
439        let term = fac * (a2 * (k as f64) * (k as f64)).exp();
440        sum += term;
441        // Convergence: stop when the term is negligible relative to the sum.
442        if term.abs() <= 1e-12 * sum.abs().max(1e-300) {
443            break;
444        }
445        fac = -fac;
446    }
447    // Numerical safety: the result is a probability in [0, 1].
448    sum.clamp(0.0, 1.0)
449}
450
451/// Compute the two-sample KS test p-value from the statistic `D` and the
452/// two sample sizes `n1` and `n2`.
453///
454/// Uses the standard small-sample correction:
455/// `λ = (√(n_eff) + 0.12 + 0.11/√(n_eff)) · D` where
456/// `n_eff = n1·n2/(n1+n2)`.
457pub(crate) fn ks_pvalue(d: f64, n1: usize, n2: usize) -> f64 {
458    if d <= 0.0 || n1 == 0 || n2 == 0 {
459        return 1.0;
460    }
461    let n_eff = (n1 as f64 * n2 as f64) / (n1 + n2) as f64;
462    let lambda = (n_eff.sqrt() + 0.12 + 0.11 / n_eff.sqrt()) * d;
463    ks_survival(lambda)
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// Deterministic pseudo-random number in `[0, 1)` using a simple LCG.
471    fn next_unit(seed: &mut u64) -> f64 {
472        *seed = seed
473            .wrapping_mul(6364136223846793005)
474            .wrapping_add(1442695040888963407);
475        ((*seed >> 11) as f64) / ((1u64 << 53) as f64)
476    }
477
478    /// Standard normal sample via Box-Muller transform.
479    fn next_normal(seed: &mut u64, mean: f64, std: f64) -> f64 {
480        let u1 = next_unit(seed).max(1e-10);
481        let u2 = next_unit(seed);
482        let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
483        mean + std * z
484    }
485
486    #[test]
487    fn default_config_is_valid() {
488        let kswin = Kswin::default();
489        assert_eq!(kswin.samples_seen(), 0);
490        assert_eq!(kswin.level(), DriftLevel::None);
491        assert!(!kswin.detected());
492        assert!(!kswin.warning());
493        assert_eq!(kswin.reference_window_len(), 0);
494        assert_eq!(kswin.current_window_len(), 0);
495    }
496
497    #[test]
498    fn detects_mean_shift() {
499        let mut kswin = Kswin::new(KswinConfig {
500            alpha: 0.01,
501            window_size: 50,
502            check_interval: 50,
503        })
504        .unwrap();
505
506        let mut seed = 42u64;
507        // Phase 1: mean 0, small noise.
508        for _ in 0..100 {
509            let v = next_normal(&mut seed, 0.0, 0.5);
510            kswin.update(v).unwrap();
511        }
512        assert_eq!(kswin.level(), DriftLevel::None);
513
514        // Phase 2: mean 5.
515        let mut detected = false;
516        for _ in 0..150 {
517            let v = next_normal(&mut seed, 5.0, 0.5);
518            let level = kswin.update(v).unwrap();
519            if level == DriftLevel::Drift {
520                detected = true;
521                break;
522            }
523        }
524        assert!(detected, "KSWIN should detect the mean shift");
525    }
526
527    #[test]
528    fn detects_variance_change() {
529        let mut kswin = Kswin::new(KswinConfig {
530            alpha: 0.01,
531            window_size: 50,
532            check_interval: 50,
533        })
534        .unwrap();
535
536        let mut seed = 7u64;
537        // Phase 1: small variance (std = 0.1).
538        for _ in 0..100 {
539            let v = next_normal(&mut seed, 0.0, 0.1);
540            kswin.update(v).unwrap();
541        }
542        assert_eq!(kswin.level(), DriftLevel::None);
543
544        // Phase 2: large variance (std = 3.0).
545        let mut detected = false;
546        for _ in 0..150 {
547            let v = next_normal(&mut seed, 0.0, 3.0);
548            let level = kswin.update(v).unwrap();
549            if level == DriftLevel::Drift {
550                detected = true;
551                break;
552            }
553        }
554        assert!(
555            detected,
556            "KSWIN should detect the variance change (a distribution shape change)"
557        );
558    }
559
560    #[test]
561    fn detects_distribution_shape_change() {
562        let mut kswin = Kswin::new(KswinConfig {
563            alpha: 0.01,
564            window_size: 50,
565            check_interval: 50,
566        })
567        .unwrap();
568
569        let mut seed = 99u64;
570        // Phase 1: uniform distribution on [-0.5, 0.5].
571        for _ in 0..100 {
572            let v = next_unit(&mut seed) - 0.5;
573            kswin.update(v).unwrap();
574        }
575        assert_eq!(kswin.level(), DriftLevel::None);
576
577        // Phase 2: normal distribution with mean 0, std 1 (different shape).
578        let mut detected = false;
579        for _ in 0..200 {
580            let v = next_normal(&mut seed, 0.0, 1.0);
581            let level = kswin.update(v).unwrap();
582            if level == DriftLevel::Drift {
583                detected = true;
584                break;
585            }
586        }
587        assert!(
588            detected,
589            "KSWIN should detect the distribution shape change (uniform -> normal)"
590        );
591    }
592
593    #[test]
594    fn no_false_positive_on_stable_stream() {
595        let mut kswin = Kswin::new(KswinConfig {
596            alpha: 0.005,
597            window_size: 100,
598            check_interval: 100,
599        })
600        .unwrap();
601
602        let mut seed = 13u64;
603        for _ in 0..1000 {
604            let v = next_normal(&mut seed, 0.0, 1.0);
605            kswin.update(v).unwrap();
606        }
607        assert!(
608            !kswin.detected(),
609            "false positive: drift reported on stable stream (p-value={})",
610            kswin.last_pvalue()
611        );
612    }
613
614    #[test]
615    fn portable_state_restore_preserves_future_results() {
616        let config = KswinConfig {
617            alpha: 0.01,
618            window_size: 20,
619            check_interval: 20,
620        };
621        let mut original = Kswin::new(config.clone()).unwrap();
622        for i in 0..55 {
623            original.update((i % 9) as f64 / 10.0).unwrap();
624        }
625        let state = original.export_state_v1();
626        state.validate_state().unwrap();
627        let mut restored = Kswin::restore_state_v1(config, state).unwrap();
628        for i in 0..100 {
629            let value = if i < 25 { 0.5 } else { 5.0 + (i % 3) as f64 };
630            assert_eq!(
631                original.update(value).unwrap(),
632                restored.update(value).unwrap()
633            );
634            assert_eq!(original.export_state_v1(), restored.export_state_v1());
635        }
636    }
637
638    #[test]
639    fn portable_state_rejects_mismatch_and_corruption() {
640        let detector = Kswin::default();
641        let mut wrong_config = KswinConfig::default();
642        wrong_config.check_interval += 1;
643        assert!(Kswin::restore_state_v1(wrong_config, detector.export_state_v1()).is_err());
644
645        let mut corrupt = detector.export_state_v1();
646        corrupt.last_pvalue = 2.0;
647        assert!(corrupt.validate_state().is_err());
648        let mut corrupt = detector.export_state_v1();
649        corrupt.last_check_sample = 1;
650        assert!(corrupt.validate_state().is_err());
651    }
652
653    #[test]
654    fn rejects_non_finite_input() {
655        let mut kswin = Kswin::default();
656        assert!(kswin.update(f64::NAN).is_err());
657        assert!(kswin.update(f64::INFINITY).is_err());
658        assert!(kswin.update(f64::NEG_INFINITY).is_err());
659        assert_eq!(kswin.samples_seen(), 0);
660    }
661
662    #[test]
663    fn rejects_invalid_config() {
664        // alpha <= 0
665        assert!(
666            Kswin::new(KswinConfig {
667                alpha: 0.0,
668                ..Default::default()
669            })
670            .is_err()
671        );
672        // alpha >= 1
673        assert!(
674            Kswin::new(KswinConfig {
675                alpha: 1.0,
676                ..Default::default()
677            })
678            .is_err()
679        );
680        // alpha NaN
681        assert!(
682            Kswin::new(KswinConfig {
683                alpha: f64::NAN,
684                ..Default::default()
685            })
686            .is_err()
687        );
688        // window_size == 0
689        assert!(
690            Kswin::new(KswinConfig {
691                window_size: 0,
692                ..Default::default()
693            })
694            .is_err()
695        );
696        // check_interval == 0
697        assert!(
698            Kswin::new(KswinConfig {
699                check_interval: 0,
700                ..Default::default()
701            })
702            .is_err()
703        );
704    }
705
706    #[test]
707    fn reset_clears_state() {
708        let mut kswin = Kswin::new(KswinConfig {
709            window_size: 20,
710            check_interval: 20,
711            ..Default::default()
712        })
713        .unwrap();
714        for i in 0..50 {
715            kswin.update(i as f64).unwrap();
716        }
717        assert!(kswin.samples_seen() > 0);
718        assert!(kswin.reference_window_len() > 0 || kswin.current_window_len() > 0);
719        kswin.reset();
720        assert_eq!(kswin.samples_seen(), 0);
721        assert_eq!(kswin.reference_window_len(), 0);
722        assert_eq!(kswin.current_window_len(), 0);
723        assert_eq!(kswin.level(), DriftLevel::None);
724        assert_eq!(kswin.last_pvalue(), 1.0);
725        assert_eq!(kswin.last_statistic(), 0.0);
726    }
727
728    #[test]
729    fn min_samples_gates_detection() {
730        let mut kswin = Kswin::new(KswinConfig {
731            alpha: 0.001,
732            window_size: 50,
733            check_interval: 50,
734        })
735        .unwrap();
736        // Before both windows are full, no detection should occur even with
737        // extreme distribution differences.
738        for _ in 0..49 {
739            kswin.update(0.0).unwrap();
740        }
741        assert_eq!(kswin.level(), DriftLevel::None);
742        // Even with extreme values in the current window (not yet full),
743        // detection cannot fire.
744        for _ in 0..49 {
745            kswin.update(1000.0).unwrap();
746        }
747        // Reference is full (50 from first phase), current has 49 — not full yet.
748        assert_eq!(kswin.level(), DriftLevel::None);
749    }
750
751    #[test]
752    fn ks_statistic_symmetric() {
753        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
754        let b = [1.5, 2.5, 3.5, 4.5, 5.5];
755        let d_ab = ks_statistic(&a, &b);
756        let d_ba = ks_statistic(&b, &a);
757        assert!(
758            (d_ab - d_ba).abs() < 1e-12,
759            "ks_statistic should be symmetric: {} vs {}",
760            d_ab,
761            d_ba
762        );
763    }
764
765    #[test]
766    fn ks_statistic_identical_distributions() {
767        let a = [1.0, 2.0, 3.0, 4.0, 5.0];
768        let b = [1.0, 2.0, 3.0, 4.0, 5.0];
769        let d = ks_statistic(&a, &b);
770        assert!(
771            d.abs() < 1e-12,
772            "ks_statistic of identical samples should be 0, got {}",
773            d
774        );
775    }
776
777    #[test]
778    fn ks_statistic_disjoint_distributions() {
779        let a = [1.0, 2.0, 3.0];
780        let b = [10.0, 11.0, 12.0];
781        let d = ks_statistic(&a, &b);
782        assert!(
783            (d - 1.0).abs() < 1e-12,
784            "ks_statistic of disjoint samples should be 1, got {}",
785            d
786        );
787    }
788
789    #[test]
790    fn ks_pvalue_decreases_with_larger_d() {
791        let n1 = 50usize;
792        let n2 = 50usize;
793        let p_small = ks_pvalue(0.1, n1, n2);
794        let p_medium = ks_pvalue(0.3, n1, n2);
795        let p_large = ks_pvalue(0.6, n1, n2);
796        assert!(
797            p_small > p_medium,
798            "p-value should decrease as D increases: {} vs {}",
799            p_small,
800            p_medium
801        );
802        assert!(
803            p_medium > p_large,
804            "p-value should decrease as D increases: {} vs {}",
805            p_medium,
806            p_large
807        );
808    }
809
810    #[test]
811    fn ks_survival_known_values() {
812        // λ = 0 → no evidence against H0 → p-value = 1.
813        assert!((ks_survival(0.0) - 1.0).abs() < 1e-12);
814        // λ very large → strong evidence → p-value ≈ 0.
815        assert!(ks_survival(10.0) < 1e-10);
816        // Monotonically decreasing.
817        let p1 = ks_survival(0.5);
818        let p2 = ks_survival(1.0);
819        let p3 = ks_survival(2.0);
820        assert!(p1 > p2);
821        assert!(p2 > p3);
822        // Known value: Q_KS(1) ≈ 0.27 (Numerical Recipes table).
823        assert!(
824            (ks_survival(1.0) - 0.27).abs() < 0.02,
825            "Q_KS(1) should be approximately 0.27, got {}",
826            ks_survival(1.0)
827        );
828    }
829
830    #[test]
831    fn ks_pvalue_is_in_unit_interval() {
832        let mut seed = 42u64;
833        for _ in 0..100 {
834            let d = next_unit(&mut seed); // [0, 1)
835            let p = ks_pvalue(d, 30, 40);
836            assert!(
837                (0.0..=1.0).contains(&p),
838                "p-value out of range: {} for d={}",
839                p,
840                d
841            );
842        }
843    }
844
845    #[test]
846    fn window_rotation_after_drift() {
847        let mut kswin = Kswin::new(KswinConfig {
848            alpha: 0.05,
849            window_size: 30,
850            check_interval: 30,
851        })
852        .unwrap();
853        // Phase 1: fill reference with 0s.
854        for _ in 0..30 {
855            kswin.update(0.0).unwrap();
856        }
857        // Phase 2: fill current with 100s (very different distribution).
858        let mut detected = false;
859        for _ in 0..60 {
860            let level = kswin.update(100.0).unwrap();
861            if level == DriftLevel::Drift {
862                detected = true;
863                break;
864            }
865        }
866        assert!(detected, "should detect the distribution change");
867        // After drift detection, the current window was rotated to reference.
868        // The reference window should now contain the new distribution (100s).
869        assert!(
870            kswin.reference_window_len() > 0,
871            "reference window should have data after rotation"
872        );
873        // The mean of the reference window should be close to 100.
874        let ref_mean: f64 =
875            kswin.reference_window.iter().sum::<f64>() / kswin.reference_window.len() as f64;
876        assert!(
877            (ref_mean - 100.0).abs() < 1e-9,
878            "reference should contain the new distribution, mean={}",
879            ref_mean
880        );
881    }
882
883    #[test]
884    fn last_value_returns_last_pvalue() {
885        let mut kswin = Kswin::new(KswinConfig {
886            alpha: 0.05,
887            window_size: 20,
888            check_interval: 20,
889        })
890        .unwrap();
891        // Before any check, last_value should be the initial p-value (1.0).
892        assert!((kswin.last_value() - 1.0).abs() < 1e-12);
893        // Feed enough data to trigger a check.
894        for _ in 0..20 {
895            kswin.update(0.0).unwrap();
896        }
897        for _ in 0..20 {
898            kswin.update(0.0).unwrap();
899        }
900        // After a check on identical distributions, p-value should be high.
901        assert!(
902            kswin.last_value() > 0.5,
903            "p-value for identical distributions should be high, got {}",
904            kswin.last_value()
905        );
906    }
907
908    #[cfg(feature = "serde")]
909    #[test]
910    fn serde_roundtrip() {
911        let mut kswin = Kswin::new(KswinConfig {
912            alpha: 0.01,
913            window_size: 30,
914            check_interval: 30,
915        })
916        .unwrap();
917        for i in 0..60 {
918            kswin.update(i as f64 * 0.1).unwrap();
919        }
920        let json = serde_json::to_string(&kswin).unwrap();
921        let restored: Kswin = serde_json::from_str(&json).unwrap();
922        assert_eq!(restored.samples_seen(), 60);
923        assert_eq!(restored.config().window_size, 30);
924        assert_eq!(restored.config().alpha, 0.01);
925        assert!((restored.last_pvalue() - kswin.last_pvalue()).abs() < 1e-12);
926        assert!((restored.last_statistic() - kswin.last_statistic()).abs() < 1e-12);
927    }
928
929    #[cfg(feature = "serde")]
930    #[test]
931    fn config_serde_roundtrip() {
932        let config = KswinConfig {
933            alpha: 0.007,
934            window_size: 75,
935            check_interval: 50,
936        };
937        let json = serde_json::to_string(&config).unwrap();
938        let restored: KswinConfig = serde_json::from_str(&json).unwrap();
939        assert!((restored.alpha - 0.007).abs() < 1e-12);
940        assert_eq!(restored.window_size, 75);
941        assert_eq!(restored.check_interval, 50);
942    }
943}