Skip to main content

ipfrs_semantic/
embedding_drift_detector.rs

1//! # EmbeddingDriftDetector
2//!
3//! Production-quality concept drift detection in high-dimensional embedding spaces.
4//!
5//! Supports multiple statistical methods:
6//! - **CentroidDistance** — Euclidean distance between window centroids
7//! - **KLDivergence** — Per-dimension Gaussian KL divergence approximation
8//! - **PageHinkley** — Sequential change-point detection
9//! - **ADWIN** — Adaptive windowing with sub-window comparison
10//! - **CUSUMDetector** — Cumulative sum control chart on centroid norms
11//!
12//! ## Example
13//!
14//! ```rust
15//! use ipfrs_semantic::embedding_drift_detector::{
16//!     EmbeddingDriftDetector, DetectorConfig, DetectionMethod,
17//! };
18//!
19//! let config = DetectorConfig {
20//!     method: DetectionMethod::CentroidDistance(0.3),
21//!     window_size: 50,
22//!     reference_window_size: 50,
23//!     min_samples_before_detect: 20,
24//!     drift_threshold: 0.3,
25//! };
26//! let mut detector = EmbeddingDriftDetector::new(config);
27//!
28//! // Feed reference embeddings
29//! for i in 0..50_u64 {
30//!     let emb = vec![0.1_f64, 0.2, 0.3];
31//!     let _ = detector.add_sample(emb, i);
32//! }
33//!
34//! // Trigger a drift
35//! for i in 50..100_u64 {
36//!     let emb = vec![0.9_f64, 0.8, 0.7];
37//!     let _ = detector.add_sample(emb, i);
38//! }
39//! println!("Stats: {:?}", detector.stats());
40//! ```
41
42use std::collections::VecDeque;
43use std::fmt;
44
45// ─── Helpers ────────────────────────────────────────────────────────────────
46
47/// Arithmetic mean of a slice; returns 0.0 for empty input.
48#[inline]
49fn stat_mean(data: &[f64]) -> f64 {
50    if data.is_empty() {
51        return 0.0;
52    }
53    data.iter().sum::<f64>() / data.len() as f64
54}
55
56/// Unbiased sample variance of a slice of values; returns 0.0 for fewer than two elements.
57/// Used for per-window scalar statistics (e.g., norm distributions).
58#[inline]
59fn stat_variance(data: &[f64]) -> f64 {
60    if data.len() < 2 {
61        return 0.0;
62    }
63    let m = stat_mean(data);
64    data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (data.len() - 1) as f64
65}
66
67/// Cosine distance in [0, 2] (1 – cosine_similarity).
68/// Returns 1.0 if lengths differ or either vector is a near-zero vector.
69fn cosine_distance(a: &[f64], b: &[f64]) -> f64 {
70    if a.len() != b.len() || a.is_empty() {
71        return 1.0;
72    }
73    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
74    let na = a.iter().map(|x| x * x).sum::<f64>().sqrt();
75    let nb = b.iter().map(|x| x * x).sum::<f64>().sqrt();
76    if na < 1e-10 || nb < 1e-10 {
77        1.0
78    } else {
79        1.0 - (dot / (na * nb)).clamp(-1.0, 1.0)
80    }
81}
82
83/// Euclidean distance between two equal-length vectors.
84fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
85    if a.len() != b.len() {
86        return f64::INFINITY;
87    }
88    a.iter()
89        .zip(b.iter())
90        .map(|(x, y)| (x - y).powi(2))
91        .sum::<f64>()
92        .sqrt()
93}
94
95// ─── PRNG (no rand crate) ───────────────────────────────────────────────────
96
97#[inline]
98fn xorshift64(state: &mut u64) -> u64 {
99    let mut x = *state;
100    x ^= x << 13;
101    x ^= x >> 7;
102    x ^= x << 17;
103    *state = x;
104    x
105}
106
107#[inline]
108fn xorshift_f64(state: &mut u64) -> f64 {
109    (xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64
110}
111
112// ─── Public Error Type ──────────────────────────────────────────────────────
113
114/// Errors produced by [`EmbeddingDriftDetector`].
115#[derive(Debug, Clone, PartialEq)]
116pub enum DetectorError {
117    /// Not enough samples to perform detection.
118    InsufficientData(usize),
119    /// Embedding dimension does not match the established dimension.
120    DimensionMismatch {
121        /// Expected embedding dimension.
122        expected: usize,
123        /// Received embedding dimension.
124        got: usize,
125    },
126    /// The rolling window has no samples.
127    WindowEmpty,
128    /// Invalid detector configuration.
129    ConfigurationError(String),
130}
131
132impl fmt::Display for DetectorError {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::InsufficientData(n) => write!(f, "Insufficient data: {n} samples"),
136            Self::DimensionMismatch { expected, got } => {
137                write!(f, "Dimension mismatch: expected {expected}, got {got}")
138            }
139            Self::WindowEmpty => write!(f, "Rolling window is empty"),
140            Self::ConfigurationError(msg) => write!(f, "Configuration error: {msg}"),
141        }
142    }
143}
144
145impl std::error::Error for DetectorError {}
146
147// ─── Detection Methods ──────────────────────────────────────────────────────
148
149/// Statistical method used to decide whether drift has occurred.
150#[derive(Debug, Clone, PartialEq)]
151pub enum DetectionMethod {
152    /// Euclidean distance between window centroids; field is the drift threshold.
153    CentroidDistance(f64),
154    /// Approximate KL divergence (per-dim Gaussian); field is the drift threshold.
155    KLDivergence(f64),
156    /// Page-Hinkley sequential change-point test.
157    /// `delta` is the expected magnitude of normal change; `lambda` is the cumulative threshold.
158    PageHinkley { delta: f64, lambda: f64 },
159    /// Adaptive windowing (ADWIN-style) sub-window comparison.
160    /// `delta` is the significance parameter.
161    ADWIN { delta: f64 },
162    /// Standard CUSUM on centroid norms.
163    /// `k` is the allowable slack; `h` is the decision threshold.
164    CUSUMDetector { k: f64, h: f64 },
165}
166
167// ─── Drift Classification ───────────────────────────────────────────────────
168
169/// Classification of the kind of drift that was detected.
170#[derive(Debug, Clone, PartialEq)]
171pub enum DriftType {
172    /// Centroid shift — the distribution mean has moved.
173    ConceptDrift,
174    /// Spread change — variance has grown or shrunk.
175    VarianceDrift,
176    /// Single-dimension shift.
177    DimensionDrift {
178        /// The dimension index that drifted.
179        dim: usize,
180    },
181    /// Periodic / seasonal pattern.
182    SeasonalDrift,
183    /// Slow, gradual drift.
184    GradualDrift,
185    /// Abrupt, sudden change.
186    SuddenDrift,
187    /// Previously seen distribution has returned.
188    RecurringDrift,
189}
190
191// ─── Core Data Structures ───────────────────────────────────────────────────
192
193/// A statistical snapshot of the current embedding window.
194#[derive(Debug, Clone)]
195pub struct DriftSnapshot {
196    /// Unique identifier for this snapshot (UUID-style hex string).
197    pub snapshot_id: String,
198    /// Unix timestamp (ms) when this snapshot was taken.
199    pub timestamp: u64,
200    /// Per-dimension mean of the window.
201    pub centroid: Vec<f64>,
202    /// Aggregate variance (mean of per-dimension variances).
203    pub variance: f64,
204    /// Number of embeddings included.
205    pub sample_count: usize,
206    /// Per-dimension unbiased sample variance.
207    pub covariance_diagonal: Vec<f64>,
208}
209
210/// A drift event produced when change is detected.
211#[derive(Debug, Clone)]
212pub struct DriftSignal {
213    /// Identifier of the detector that produced this signal.
214    pub detector_id: String,
215    /// Classification of the drift kind.
216    pub signal_type: DriftType,
217    /// Magnitude in [0.0, 1.0].
218    pub magnitude: f64,
219    /// Statistical confidence in [0.0, 1.0].
220    pub confidence: f64,
221    /// Unix timestamp (ms) when the drift was detected.
222    pub detected_at: u64,
223    /// Dimensions that contributed most to the drift.
224    pub affected_dimensions: Vec<usize>,
225}
226
227// ─── Detector Config ────────────────────────────────────────────────────────
228
229/// Configuration for [`EmbeddingDriftDetector`].
230#[derive(Debug, Clone)]
231pub struct DetectorConfig {
232    /// Statistical detection method.
233    pub method: DetectionMethod,
234    /// Number of most-recent samples kept in the rolling window.
235    pub window_size: usize,
236    /// Size of the reference (baseline) window.
237    pub reference_window_size: usize,
238    /// Minimum samples before drift detection is attempted.
239    pub min_samples_before_detect: usize,
240    /// Unified drift threshold (also used inside method-specific logic where needed).
241    pub drift_threshold: f64,
242}
243
244impl Default for DetectorConfig {
245    fn default() -> Self {
246        Self {
247            method: DetectionMethod::CentroidDistance(0.3),
248            window_size: 100,
249            reference_window_size: 100,
250            min_samples_before_detect: 20,
251            drift_threshold: 0.3,
252        }
253    }
254}
255
256// ─── Stats ──────────────────────────────────────────────────────────────────
257
258/// Aggregate statistics for a running [`EmbeddingDriftDetector`].
259#[derive(Debug, Clone, Default)]
260pub struct DriftStats {
261    /// Total snapshots taken.
262    pub snapshots_taken: usize,
263    /// Total drift events detected.
264    pub drifts_detected: usize,
265    /// Estimated false-positive rate (heuristic).
266    pub false_positive_estimate: f64,
267    /// Rolling average magnitude of detected drift signals.
268    pub avg_drift_magnitude: f64,
269    /// Timestamp (ms) of the most recent drift event.
270    pub last_drift_at: Option<u64>,
271}
272
273// ─── Internal Page-Hinkley state ────────────────────────────────────────────
274
275#[derive(Debug, Clone, Default)]
276struct PageHinkleyState {
277    cumsum_pos: f64,
278    cumsum_neg: f64,
279    running_mean: f64,
280    n: usize,
281}
282
283// ─── Internal CUSUM state ────────────────────────────────────────────────────
284
285#[derive(Debug, Clone, Default)]
286struct CusumState {
287    cumsum_pos: f64,
288    cumsum_neg: f64,
289    running_mean: f64,
290    n: usize,
291}
292
293// ─── Main Detector ──────────────────────────────────────────────────────────
294
295/// Detects concept drift in a stream of high-dimensional embeddings.
296///
297/// Maintains a rolling window and a reference window; when the rolling window
298/// fills it is compared to the reference window using the configured
299/// [`DetectionMethod`].
300pub struct EmbeddingDriftDetector {
301    /// Detector identifier (used in emitted [`DriftSignal`] values).
302    pub id: String,
303    /// Configuration.
304    pub config: DetectorConfig,
305
306    // Internal rolling window (most-recent samples).
307    rolling: VecDeque<Vec<f64>>,
308    // Reference / baseline window.
309    reference: VecDeque<Vec<f64>>,
310    // Established embedding dimensionality (set on first sample).
311    dim: Option<usize>,
312
313    // Page-Hinkley / CUSUM per-detector state.
314    ph_state: PageHinkleyState,
315    cusum_state: CusumState,
316
317    // PRNG state for jitter / tie-breaking.
318    rng_state: u64,
319
320    // History (capped at 100 entries).
321    history: VecDeque<DriftSignal>,
322
323    // Running stats.
324    stats: DriftStats,
325
326    // Drift magnitude accumulator (for rolling average).
327    magnitude_sum: f64,
328
329    // Snapshot counter.
330    snapshot_counter: u64,
331}
332
333impl EmbeddingDriftDetector {
334    /// Creates a new detector with the given configuration.
335    pub fn new(config: DetectorConfig) -> Self {
336        // Simple entropy seed based on detector construction order.
337        static COUNTER: std::sync::atomic::AtomicU64 =
338            std::sync::atomic::AtomicU64::new(0x6d2c_a897_fc3b_e14d);
339        let seed = COUNTER.fetch_add(0x9e37_79b9_7f4a_7c15, std::sync::atomic::Ordering::Relaxed);
340
341        Self {
342            id: format!("edd-{seed:016x}"),
343            config,
344            rolling: VecDeque::new(),
345            reference: VecDeque::new(),
346            dim: None,
347            ph_state: PageHinkleyState::default(),
348            cusum_state: CusumState::default(),
349            rng_state: seed | 1, // ensure non-zero
350            history: VecDeque::new(),
351            stats: DriftStats::default(),
352            magnitude_sum: 0.0,
353            snapshot_counter: 0,
354        }
355    }
356
357    /// Creates a new detector with a custom string identifier.
358    pub fn with_id(id: impl Into<String>, config: DetectorConfig) -> Self {
359        let mut det = Self::new(config);
360        det.id = id.into();
361        det
362    }
363
364    // ── Public API ──────────────────────────────────────────────────────────
365
366    /// Adds one embedding to the rolling window.
367    ///
368    /// Returns `Ok(Some(signal))` when drift is detected, `Ok(None)` otherwise.
369    pub fn add_sample(
370        &mut self,
371        embedding: Vec<f64>,
372        timestamp: u64,
373    ) -> Result<Option<DriftSignal>, DetectorError> {
374        // Dimension check / initialisation.
375        match self.dim {
376            None => {
377                if embedding.is_empty() {
378                    return Err(DetectorError::ConfigurationError(
379                        "empty embedding vector".into(),
380                    ));
381                }
382                self.dim = Some(embedding.len());
383            }
384            Some(d) if d != embedding.len() => {
385                return Err(DetectorError::DimensionMismatch {
386                    expected: d,
387                    got: embedding.len(),
388                });
389            }
390            _ => {}
391        }
392
393        // Update sequential-test running stats.
394        let norm = embedding.iter().map(|x| x * x).sum::<f64>().sqrt();
395        self.update_ph_state(norm);
396        self.update_cusum_state(norm);
397
398        // Maintain rolling window.
399        self.rolling.push_back(embedding);
400        if self.rolling.len() > self.config.window_size {
401            self.rolling.pop_front();
402        }
403
404        // Seed the reference window on first fill.
405        if self.reference.is_empty() && self.rolling.len() == self.config.window_size {
406            for emb in &self.rolling {
407                self.reference.push_back(emb.clone());
408            }
409        }
410
411        // Attempt detection once we have enough samples.
412        if self.rolling.len() < self.config.min_samples_before_detect || self.reference.is_empty() {
413            return Ok(None);
414        }
415
416        // Only perform snapshot comparison when the window is full.
417        if self.rolling.len() < self.config.window_size {
418            return Ok(None);
419        }
420
421        let snap_current = self.take_snapshot(timestamp)?;
422        self.stats.snapshots_taken += 1;
423
424        // Build reference snapshot directly from reference window.
425        let snap_ref = self.snapshot_from_window(&self.reference.clone(), timestamp)?;
426
427        let maybe_signal = self.compare_snapshots(&snap_ref, &snap_current)?;
428        if maybe_signal.magnitude > 0.0 {
429            self.record_drift(&maybe_signal);
430            return Ok(Some(maybe_signal));
431        }
432
433        Ok(None)
434    }
435
436    /// Computes a snapshot of the current rolling window.
437    pub fn take_snapshot(&mut self, timestamp: u64) -> Result<DriftSnapshot, DetectorError> {
438        self.snapshot_from_window(&self.rolling.clone(), timestamp)
439    }
440
441    /// Compares two snapshots using the configured [`DetectionMethod`].
442    ///
443    /// Returns a [`DriftSignal`] whose `magnitude` is `0.0` if no drift was
444    /// detected, and positive otherwise.
445    pub fn compare_snapshots(
446        &self,
447        reference: &DriftSnapshot,
448        current: &DriftSnapshot,
449    ) -> Result<DriftSignal, DetectorError> {
450        if reference.centroid.len() != current.centroid.len() {
451            return Err(DetectorError::DimensionMismatch {
452                expected: reference.centroid.len(),
453                got: current.centroid.len(),
454            });
455        }
456        if reference.sample_count == 0 || current.sample_count == 0 {
457            return Err(DetectorError::InsufficientData(0));
458        }
459
460        match &self.config.method {
461            DetectionMethod::CentroidDistance(threshold) => {
462                self.detect_centroid_distance(reference, current, *threshold)
463            }
464            DetectionMethod::KLDivergence(threshold) => {
465                self.detect_kl_divergence(reference, current, *threshold)
466            }
467            DetectionMethod::PageHinkley { delta, lambda } => {
468                self.detect_page_hinkley(reference, current, *delta, *lambda)
469            }
470            DetectionMethod::ADWIN { delta } => self.detect_adwin(reference, current, *delta),
471            DetectionMethod::CUSUMDetector { k, h } => {
472                self.detect_cusum(reference, current, *k, *h)
473            }
474        }
475    }
476
477    /// Copies the current rolling window into the reference window.
478    pub fn reset_reference(&mut self) -> Result<(), DetectorError> {
479        if self.rolling.is_empty() {
480            return Err(DetectorError::WindowEmpty);
481        }
482        self.reference.clear();
483        for emb in &self.rolling {
484            self.reference.push_back(emb.clone());
485        }
486        // Reset sequential-test accumulators.
487        self.ph_state = PageHinkleyState::default();
488        self.cusum_state = CusumState::default();
489        Ok(())
490    }
491
492    /// Returns the last 100 drift signals (oldest first).
493    pub fn drift_history(&self) -> Vec<DriftSignal> {
494        self.history.iter().cloned().collect()
495    }
496
497    /// Returns aggregate statistics for this detector.
498    pub fn stats(&self) -> DriftStats {
499        self.stats.clone()
500    }
501
502    /// Returns the number of samples in the rolling window.
503    pub fn window_len(&self) -> usize {
504        self.rolling.len()
505    }
506
507    /// Returns the number of samples in the reference window.
508    pub fn reference_len(&self) -> usize {
509        self.reference.len()
510    }
511
512    /// Returns the established embedding dimension (if any sample has been added).
513    pub fn embedding_dim(&self) -> Option<usize> {
514        self.dim
515    }
516
517    // ── Internal helpers ────────────────────────────────────────────────────
518
519    fn snapshot_from_window(
520        &mut self,
521        window: &VecDeque<Vec<f64>>,
522        timestamp: u64,
523    ) -> Result<DriftSnapshot, DetectorError> {
524        if window.is_empty() {
525            return Err(DetectorError::WindowEmpty);
526        }
527        let dim = window[0].len();
528        let n = window.len() as f64;
529
530        // Compute per-dimension mean.
531        let mut centroid = vec![0.0_f64; dim];
532        for emb in window {
533            for (c, &v) in centroid.iter_mut().zip(emb.iter()) {
534                *c += v;
535            }
536        }
537        for c in &mut centroid {
538            *c /= n;
539        }
540
541        // Compute per-dimension unbiased sample variance.
542        let mut cov_diag = vec![0.0_f64; dim];
543        if window.len() >= 2 {
544            for emb in window {
545                for d in 0..dim {
546                    cov_diag[d] += (emb[d] - centroid[d]).powi(2);
547                }
548            }
549            for v in &mut cov_diag {
550                *v /= n - 1.0;
551            }
552        }
553
554        let variance = stat_mean(&cov_diag);
555
556        self.snapshot_counter += 1;
557        let snap_id = format!("{}-snap-{:08x}", self.id, self.snapshot_counter);
558
559        Ok(DriftSnapshot {
560            snapshot_id: snap_id,
561            timestamp,
562            centroid,
563            variance,
564            sample_count: window.len(),
565            covariance_diagonal: cov_diag,
566        })
567    }
568
569    fn record_drift(&mut self, signal: &DriftSignal) {
570        self.stats.drifts_detected += 1;
571        self.magnitude_sum += signal.magnitude;
572        self.stats.avg_drift_magnitude = self.magnitude_sum / self.stats.drifts_detected as f64;
573        self.stats.last_drift_at = Some(signal.detected_at);
574
575        // Heuristic false-positive estimate:
576        // proportion of detections where magnitude < threshold / 2.
577        let low_mag_count = self
578            .history
579            .iter()
580            .filter(|s| s.magnitude < self.config.drift_threshold / 2.0)
581            .count() as f64;
582        self.stats.false_positive_estimate = low_mag_count / self.stats.drifts_detected as f64;
583
584        self.history.push_back(signal.clone());
585        if self.history.len() > 100 {
586            self.history.pop_front();
587        }
588    }
589
590    fn make_signal(
591        &self,
592        signal_type: DriftType,
593        magnitude: f64,
594        confidence: f64,
595        timestamp: u64,
596        affected: Vec<usize>,
597    ) -> DriftSignal {
598        DriftSignal {
599            detector_id: self.id.clone(),
600            signal_type,
601            magnitude: magnitude.clamp(0.0, 1.0),
602            confidence: confidence.clamp(0.0, 1.0),
603            detected_at: timestamp,
604            affected_dimensions: affected,
605        }
606    }
607
608    /// Build a "no drift" sentinel signal.
609    fn no_drift_signal(&self, timestamp: u64) -> DriftSignal {
610        DriftSignal {
611            detector_id: self.id.clone(),
612            signal_type: DriftType::ConceptDrift,
613            magnitude: 0.0,
614            confidence: 0.0,
615            detected_at: timestamp,
616            affected_dimensions: vec![],
617        }
618    }
619
620    // ─── Detection implementations ──────────────────────────────────────────
621
622    fn detect_centroid_distance(
623        &self,
624        reference: &DriftSnapshot,
625        current: &DriftSnapshot,
626        threshold: f64,
627    ) -> Result<DriftSignal, DetectorError> {
628        let dist = euclidean_distance(&reference.centroid, &current.centroid);
629        // Cosine distance provides directional evidence complementing Euclidean distance.
630        let cos_dist = cosine_distance(&reference.centroid, &current.centroid);
631        let ts = current.timestamp;
632
633        if dist <= threshold {
634            return Ok(self.no_drift_signal(ts));
635        }
636
637        // Per-dimension contribution.
638        let mut dim_deltas: Vec<(usize, f64)> = reference
639            .centroid
640            .iter()
641            .zip(current.centroid.iter())
642            .enumerate()
643            .map(|(i, (a, b))| (i, (b - a).abs()))
644            .collect();
645        dim_deltas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
646        let top: Vec<usize> = dim_deltas.iter().take(5).map(|(i, _)| *i).collect();
647
648        let magnitude = ((dist - threshold) / threshold).clamp(0.0, 1.0);
649        // Blend Euclidean-based magnitude with cosine distance evidence for confidence.
650        let confidence = (1.0 - (-magnitude * 3.0).exp()) * (0.8 + 0.2 * cos_dist).min(1.0);
651
652        // Classify: large variance change → VarianceDrift; otherwise ConceptDrift.
653        let var_ratio = if reference.variance > 1e-12 {
654            (current.variance - reference.variance).abs() / reference.variance
655        } else {
656            0.0
657        };
658        let drift_type = if var_ratio > 0.5 {
659            DriftType::VarianceDrift
660        } else {
661            DriftType::ConceptDrift
662        };
663
664        Ok(self.make_signal(drift_type, magnitude, confidence, ts, top))
665    }
666
667    fn detect_kl_divergence(
668        &self,
669        reference: &DriftSnapshot,
670        current: &DriftSnapshot,
671        threshold: f64,
672    ) -> Result<DriftSignal, DetectorError> {
673        let dim = reference.centroid.len();
674        let ts = current.timestamp;
675        let eps = 1e-8;
676
677        // Per-dimension Gaussian KL: Σ[ln(σ_b/σ_a) + (σ_a² + (μ_a-μ_b)²)/(2σ_b²) - 0.5]
678        let mut total_kl = 0.0_f64;
679        let mut dim_kl: Vec<(usize, f64)> = Vec::with_capacity(dim);
680
681        for d in 0..dim {
682            let mu_a = reference.centroid[d];
683            let mu_b = current.centroid[d];
684            let sigma_a_sq = reference.covariance_diagonal[d].max(eps);
685            let sigma_b_sq = current.covariance_diagonal[d].max(eps);
686            let sigma_a = sigma_a_sq.sqrt();
687            let sigma_b = sigma_b_sq.sqrt();
688            let diff = mu_a - mu_b;
689
690            let kl =
691                (sigma_b / sigma_a).ln() + (sigma_a_sq + diff * diff) / (2.0 * sigma_b_sq) - 0.5;
692            let kl = kl.max(0.0); // Numerical guard — KL ≥ 0.
693            dim_kl.push((d, kl));
694            total_kl += kl;
695        }
696
697        let avg_kl = total_kl / dim as f64;
698
699        if avg_kl <= threshold {
700            return Ok(self.no_drift_signal(ts));
701        }
702
703        dim_kl.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
704        let affected: Vec<usize> = dim_kl.iter().take(5).map(|(i, _)| *i).collect();
705
706        let magnitude = ((avg_kl - threshold) / (threshold + 1.0)).clamp(0.0, 1.0);
707        let confidence = 1.0 - (-avg_kl).exp();
708
709        Ok(self.make_signal(DriftType::ConceptDrift, magnitude, confidence, ts, affected))
710    }
711
712    fn detect_page_hinkley(
713        &self,
714        reference: &DriftSnapshot,
715        current: &DriftSnapshot,
716        delta: f64,
717        lambda: f64,
718    ) -> Result<DriftSignal, DetectorError> {
719        let ts = current.timestamp;
720
721        // Compute per-dimension mean change over the windows.
722        let dim = reference.centroid.len();
723        let mut cumsum_pos = 0.0_f64;
724        let mut cumsum_neg = 0.0_f64;
725        let mut dim_changes: Vec<(usize, f64)> = Vec::with_capacity(dim);
726
727        for d in 0..dim {
728            let change = (current.centroid[d] - reference.centroid[d]).abs();
729            dim_changes.push((d, change));
730            cumsum_pos += (change - delta).max(0.0);
731            cumsum_neg += (-change - delta).max(0.0);
732        }
733
734        let test_stat = cumsum_pos.max(cumsum_neg.abs());
735
736        if test_stat <= lambda {
737            return Ok(self.no_drift_signal(ts));
738        }
739
740        dim_changes.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
741        let affected: Vec<usize> = dim_changes.iter().take(5).map(|(i, _)| *i).collect();
742
743        let magnitude = ((test_stat - lambda) / (lambda + 1.0)).clamp(0.0, 1.0);
744        let confidence = 1.0 - (-magnitude * 2.0).exp();
745
746        // Classify abruptness: high magnitude → SuddenDrift; moderate → GradualDrift.
747        let drift_type = if magnitude > 0.6 {
748            DriftType::SuddenDrift
749        } else {
750            DriftType::GradualDrift
751        };
752
753        Ok(self.make_signal(drift_type, magnitude, confidence, ts, affected))
754    }
755
756    fn detect_adwin(
757        &self,
758        reference: &DriftSnapshot,
759        current: &DriftSnapshot,
760        delta: f64,
761    ) -> Result<DriftSignal, DetectorError> {
762        let ts = current.timestamp;
763        let dim = reference.centroid.len();
764
765        // ADWIN: compare sub-window means. Drift if |mean_A - mean_B| > delta × variance.
766        // Use stat_variance of per-dimension variances to get a scalar spread measure.
767        let all_variances: Vec<f64> = reference
768            .covariance_diagonal
769            .iter()
770            .chain(current.covariance_diagonal.iter())
771            .copied()
772            .collect();
773        let combined_variance = stat_variance(&all_variances)
774            .max((reference.variance + current.variance) / 2.0)
775            + 1e-10;
776
777        let mut max_diff = 0.0_f64;
778        let mut dim_diffs: Vec<(usize, f64)> = Vec::with_capacity(dim);
779
780        for d in 0..dim {
781            let diff = (reference.centroid[d] - current.centroid[d]).abs();
782            let combined_var_d =
783                (reference.covariance_diagonal[d] + current.covariance_diagonal[d]) / 2.0 + 1e-10;
784            let normalised = diff / combined_var_d.sqrt();
785            dim_diffs.push((d, normalised));
786            if normalised > max_diff {
787                max_diff = normalised;
788            }
789        }
790
791        let threshold = delta * combined_variance.sqrt();
792        let centroid_dist = euclidean_distance(&reference.centroid, &current.centroid);
793
794        if centroid_dist <= threshold {
795            return Ok(self.no_drift_signal(ts));
796        }
797
798        dim_diffs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
799        let affected: Vec<usize> = dim_diffs.iter().take(5).map(|(i, _)| *i).collect();
800
801        let magnitude = ((centroid_dist - threshold) / (threshold + 1.0)).clamp(0.0, 1.0);
802        let confidence = (max_diff / (delta + 1.0)).clamp(0.0, 1.0);
803
804        Ok(self.make_signal(DriftType::ConceptDrift, magnitude, confidence, ts, affected))
805    }
806
807    fn detect_cusum(
808        &self,
809        reference: &DriftSnapshot,
810        current: &DriftSnapshot,
811        k: f64,
812        h: f64,
813    ) -> Result<DriftSignal, DetectorError> {
814        let ts = current.timestamp;
815
816        // CUSUM on the L2 norm of (current_centroid - reference_centroid).
817        let diff_norm = euclidean_distance(&reference.centroid, &current.centroid);
818        let target = reference.variance.sqrt() + 1e-8; // expected norm of small differences.
819
820        let cusum_pos = (diff_norm - target - k).max(0.0);
821        let cusum_neg = (-diff_norm + target - k).max(0.0);
822        let test_stat = cusum_pos.max(cusum_neg);
823
824        if test_stat <= h {
825            return Ok(self.no_drift_signal(ts));
826        }
827
828        // Find highest-deviation dimensions.
829        let dim = reference.centroid.len();
830        let mut dim_deltas: Vec<(usize, f64)> = (0..dim)
831            .map(|d| (d, (current.centroid[d] - reference.centroid[d]).abs()))
832            .collect();
833        dim_deltas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
834        let affected: Vec<usize> = dim_deltas.iter().take(5).map(|(i, _)| *i).collect();
835
836        let magnitude = ((test_stat - h) / (h + 1.0)).clamp(0.0, 1.0);
837        let confidence = 1.0 - (-test_stat / h).exp();
838
839        Ok(self.make_signal(DriftType::SuddenDrift, magnitude, confidence, ts, affected))
840    }
841
842    // ─── Sequential-test running state updates ──────────────────────────────
843
844    fn update_ph_state(&mut self, norm: f64) {
845        let state = &mut self.ph_state;
846        // Welford running mean.
847        state.n += 1;
848        let delta = norm - state.running_mean;
849        state.running_mean += delta / state.n as f64;
850        let change = norm - state.running_mean;
851        state.cumsum_pos = (state.cumsum_pos + change).max(0.0);
852        state.cumsum_neg = (state.cumsum_neg - change).max(0.0);
853    }
854
855    fn update_cusum_state(&mut self, norm: f64) {
856        let state = &mut self.cusum_state;
857        state.n += 1;
858        let delta = norm - state.running_mean;
859        state.running_mean += delta / state.n as f64;
860        let k = 0.5_f64; // default slack
861        state.cumsum_pos = (state.cumsum_pos + norm - state.running_mean - k).max(0.0);
862        state.cumsum_neg = (state.cumsum_neg - norm + state.running_mean - k).max(0.0);
863    }
864
865    /// Draws a pseudo-random f64 from the internal PRNG.
866    pub fn random_f64(&mut self) -> f64 {
867        xorshift_f64(&mut self.rng_state)
868    }
869}
870
871// ─── Tests ──────────────────────────────────────────────────────────────────
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876
877    // ── Helpers ──────────────────────────────────────────────────────────────
878
879    fn make_config(method: DetectionMethod) -> DetectorConfig {
880        DetectorConfig {
881            method,
882            window_size: 20,
883            reference_window_size: 20,
884            min_samples_before_detect: 10,
885            drift_threshold: 0.3,
886        }
887    }
888
889    fn constant_emb(val: f64, dim: usize) -> Vec<f64> {
890        vec![val; dim]
891    }
892
893    /// Fill a detector with `n` embeddings whose each component equals `val`.
894    fn fill(det: &mut EmbeddingDriftDetector, val: f64, dim: usize, n: usize, ts_start: u64) {
895        for i in 0..n {
896            let _ = det.add_sample(constant_emb(val, dim), ts_start + i as u64);
897        }
898    }
899
900    // ── stat_mean / stat_variance ────────────────────────────────────────────
901
902    #[test]
903    fn test_stat_mean_empty() {
904        assert_eq!(stat_mean(&[]), 0.0);
905    }
906
907    #[test]
908    fn test_stat_mean_values() {
909        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
910        assert!((stat_mean(&data) - 3.0).abs() < 1e-10);
911    }
912
913    #[test]
914    fn test_stat_variance_empty() {
915        assert_eq!(stat_variance(&[]), 0.0);
916    }
917
918    #[test]
919    fn test_stat_variance_single() {
920        assert_eq!(stat_variance(&[42.0]), 0.0);
921    }
922
923    #[test]
924    fn test_stat_variance_values() {
925        // Unbiased variance of [2, 4, 4, 4, 5, 5, 7, 9] = 4.571...
926        let data = [2.0_f64, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
927        assert!((stat_variance(&data) - 4.571_428).abs() < 1e-3);
928    }
929
930    // ── cosine_distance ───────────────────────────────────────────────────────
931
932    #[test]
933    fn test_cosine_distance_identical() {
934        let a = [1.0, 0.0, 0.0];
935        assert!(cosine_distance(&a, &a).abs() < 1e-10);
936    }
937
938    #[test]
939    fn test_cosine_distance_orthogonal() {
940        let a = [1.0, 0.0];
941        let b = [0.0, 1.0];
942        assert!((cosine_distance(&a, &b) - 1.0).abs() < 1e-10);
943    }
944
945    #[test]
946    fn test_cosine_distance_zero_vector() {
947        let a = [0.0, 0.0];
948        let b = [1.0, 1.0];
949        assert_eq!(cosine_distance(&a, &b), 1.0);
950    }
951
952    #[test]
953    fn test_cosine_distance_dim_mismatch() {
954        assert_eq!(cosine_distance(&[1.0, 0.0], &[1.0, 0.0, 0.0]), 1.0);
955    }
956
957    // ── euclidean_distance ────────────────────────────────────────────────────
958
959    #[test]
960    fn test_euclidean_same_point() {
961        let a = [1.0, 2.0, 3.0];
962        assert!(euclidean_distance(&a, &a).abs() < 1e-10);
963    }
964
965    #[test]
966    fn test_euclidean_known_distance() {
967        let a = [0.0, 0.0, 0.0];
968        let b = [3.0, 4.0, 0.0];
969        assert!((euclidean_distance(&a, &b) - 5.0).abs() < 1e-10);
970    }
971
972    #[test]
973    fn test_euclidean_dim_mismatch() {
974        assert!(euclidean_distance(&[1.0], &[1.0, 2.0]).is_infinite());
975    }
976
977    // ── PRNG ──────────────────────────────────────────────────────────────────
978
979    #[test]
980    fn test_xorshift_range() {
981        let mut state = 0xdeadbeef_cafebabe_u64;
982        for _ in 0..1000 {
983            let v = xorshift_f64(&mut state);
984            assert!((0.0..1.0).contains(&v));
985        }
986    }
987
988    #[test]
989    fn test_xorshift_non_constant() {
990        let mut state = 12345_u64;
991        let v1 = xorshift_f64(&mut state);
992        let v2 = xorshift_f64(&mut state);
993        assert!((v1 - v2).abs() > 1e-15);
994    }
995
996    // ── DriftDetector construction ────────────────────────────────────────────
997
998    #[test]
999    fn test_new_defaults() {
1000        let det = EmbeddingDriftDetector::new(DetectorConfig::default());
1001        assert_eq!(det.window_len(), 0);
1002        assert_eq!(det.reference_len(), 0);
1003        assert!(det.embedding_dim().is_none());
1004    }
1005
1006    #[test]
1007    fn test_with_id() {
1008        let det = EmbeddingDriftDetector::with_id("test-detector", DetectorConfig::default());
1009        assert_eq!(det.id, "test-detector");
1010    }
1011
1012    // ── add_sample: basic ─────────────────────────────────────────────────────
1013
1014    #[test]
1015    fn test_add_sample_increments_window() {
1016        let mut det =
1017            EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(0.3)));
1018        det.add_sample(vec![1.0, 2.0, 3.0], 0)
1019            .expect("test: add_sample should succeed for valid embedding");
1020        assert_eq!(det.window_len(), 1);
1021        assert_eq!(det.embedding_dim(), Some(3));
1022    }
1023
1024    #[test]
1025    fn test_add_sample_empty_error() {
1026        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1027        let err = det
1028            .add_sample(vec![], 0)
1029            .expect_err("test: empty embedding should return ConfigurationError");
1030        assert!(matches!(err, DetectorError::ConfigurationError(_)));
1031    }
1032
1033    #[test]
1034    fn test_add_sample_dim_mismatch() {
1035        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1036        det.add_sample(vec![1.0, 2.0], 0)
1037            .expect("test: first add_sample should succeed");
1038        let err = det
1039            .add_sample(vec![1.0, 2.0, 3.0], 1)
1040            .expect_err("test: mismatched dimension should return DimensionMismatch");
1041        assert!(matches!(
1042            err,
1043            DetectorError::DimensionMismatch {
1044                expected: 2,
1045                got: 3
1046            }
1047        ));
1048    }
1049
1050    #[test]
1051    fn test_add_sample_window_capped() {
1052        let cfg = DetectorConfig {
1053            window_size: 5,
1054            reference_window_size: 5,
1055            min_samples_before_detect: 50, // prevent detection
1056            ..make_config(DetectionMethod::CentroidDistance(0.3))
1057        };
1058        let mut det = EmbeddingDriftDetector::new(cfg);
1059        for i in 0..20_u64 {
1060            det.add_sample(vec![i as f64], i)
1061                .expect("test: add_sample should succeed for scalar embedding");
1062        }
1063        assert_eq!(det.window_len(), 5);
1064    }
1065
1066    // ── Insufficient data ──────────────────────────────────────────────────────
1067
1068    #[test]
1069    fn test_no_detection_below_min_samples() {
1070        let mut det = EmbeddingDriftDetector::new(DetectorConfig {
1071            min_samples_before_detect: 50,
1072            window_size: 20,
1073            reference_window_size: 20,
1074            ..make_config(DetectionMethod::CentroidDistance(0.3))
1075        });
1076        for i in 0..20_u64 {
1077            let result = det
1078                .add_sample(vec![0.0, 0.0], i)
1079                .expect("test: add_sample should succeed for zero embedding");
1080            assert!(result.is_none(), "should not detect with too few samples");
1081        }
1082    }
1083
1084    // ── take_snapshot ─────────────────────────────────────────────────────────
1085
1086    #[test]
1087    fn test_take_snapshot_window_empty() {
1088        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1089        let err = det
1090            .take_snapshot(0)
1091            .expect_err("test: take_snapshot on empty window should return WindowEmpty");
1092        assert_eq!(err, DetectorError::WindowEmpty);
1093    }
1094
1095    #[test]
1096    fn test_take_snapshot_centroid() {
1097        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1098        det.add_sample(vec![1.0, 2.0], 0)
1099            .expect("test: add_sample should succeed for 2d embedding");
1100        det.add_sample(vec![3.0, 4.0], 1)
1101            .expect("test: add_sample should succeed for 2d embedding");
1102        let snap = det
1103            .take_snapshot(10)
1104            .expect("test: take_snapshot should succeed after adding samples");
1105        assert!((snap.centroid[0] - 2.0).abs() < 1e-10);
1106        assert!((snap.centroid[1] - 3.0).abs() < 1e-10);
1107        assert_eq!(snap.sample_count, 2);
1108    }
1109
1110    #[test]
1111    fn test_take_snapshot_variance() {
1112        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1113        det.add_sample(vec![1.0], 0)
1114            .expect("test: add_sample should succeed for scalar embedding");
1115        det.add_sample(vec![3.0], 1)
1116            .expect("test: add_sample should succeed for scalar embedding");
1117        let snap = det
1118            .take_snapshot(2)
1119            .expect("test: take_snapshot should succeed after adding samples");
1120        // Unbiased variance of [1, 3] = 2.0
1121        assert!((snap.covariance_diagonal[0] - 2.0).abs() < 1e-10);
1122    }
1123
1124    #[test]
1125    fn test_snapshot_id_unique() {
1126        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1127        det.add_sample(vec![1.0], 0)
1128            .expect("test: add_sample should succeed for scalar embedding");
1129        let s1 = det
1130            .take_snapshot(1)
1131            .expect("test: first take_snapshot should succeed");
1132        let s2 = det
1133            .take_snapshot(2)
1134            .expect("test: second take_snapshot should succeed");
1135        assert_ne!(s1.snapshot_id, s2.snapshot_id);
1136    }
1137
1138    // ── compare_snapshots: error cases ────────────────────────────────────────
1139
1140    #[test]
1141    fn test_compare_dim_mismatch_error() {
1142        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(0.3)));
1143        let snap_a = DriftSnapshot {
1144            snapshot_id: "a".into(),
1145            timestamp: 0,
1146            centroid: vec![0.0, 0.0],
1147            variance: 0.0,
1148            sample_count: 10,
1149            covariance_diagonal: vec![0.0, 0.0],
1150        };
1151        let snap_b = DriftSnapshot {
1152            snapshot_id: "b".into(),
1153            timestamp: 1,
1154            centroid: vec![0.0, 0.0, 0.0],
1155            variance: 0.0,
1156            sample_count: 10,
1157            covariance_diagonal: vec![0.0, 0.0, 0.0],
1158        };
1159        assert!(matches!(
1160            det.compare_snapshots(&snap_a, &snap_b),
1161            Err(DetectorError::DimensionMismatch { .. })
1162        ));
1163    }
1164
1165    #[test]
1166    fn test_compare_zero_samples_error() {
1167        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(0.3)));
1168        let empty_snap = DriftSnapshot {
1169            snapshot_id: "e".into(),
1170            timestamp: 0,
1171            centroid: vec![0.0],
1172            variance: 0.0,
1173            sample_count: 0,
1174            covariance_diagonal: vec![0.0],
1175        };
1176        let good_snap = DriftSnapshot {
1177            snapshot_id: "g".into(),
1178            timestamp: 0,
1179            centroid: vec![0.0],
1180            variance: 0.0,
1181            sample_count: 5,
1182            covariance_diagonal: vec![0.0],
1183        };
1184        assert!(matches!(
1185            det.compare_snapshots(&empty_snap, &good_snap),
1186            Err(DetectorError::InsufficientData(0))
1187        ));
1188    }
1189
1190    // ── CentroidDistance: below threshold (no drift) ───────────────────────────
1191
1192    #[test]
1193    fn test_centroid_distance_no_drift() {
1194        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(1.0)));
1195        let a = DriftSnapshot {
1196            snapshot_id: "a".into(),
1197            timestamp: 0,
1198            centroid: vec![0.0, 0.0],
1199            variance: 0.1,
1200            sample_count: 20,
1201            covariance_diagonal: vec![0.1, 0.1],
1202        };
1203        let b = DriftSnapshot {
1204            snapshot_id: "b".into(),
1205            timestamp: 1,
1206            centroid: vec![0.3, 0.4],
1207            variance: 0.1,
1208            sample_count: 20,
1209            covariance_diagonal: vec![0.1, 0.1],
1210        };
1211        // Euclidean(a,b) = 0.5 < threshold 1.0 → no drift
1212        let sig = det
1213            .compare_snapshots(&a, &b)
1214            .expect("test: compare_snapshots should succeed for valid snapshots");
1215        assert_eq!(sig.magnitude, 0.0);
1216    }
1217
1218    // ── CentroidDistance: above threshold (drift) ─────────────────────────────
1219
1220    #[test]
1221    fn test_centroid_distance_drift_detected() {
1222        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(0.3)));
1223        let a = DriftSnapshot {
1224            snapshot_id: "a".into(),
1225            timestamp: 0,
1226            centroid: vec![0.0, 0.0],
1227            variance: 0.1,
1228            sample_count: 20,
1229            covariance_diagonal: vec![0.1, 0.1],
1230        };
1231        let b = DriftSnapshot {
1232            snapshot_id: "b".into(),
1233            timestamp: 1,
1234            centroid: vec![3.0, 4.0],
1235            variance: 0.1,
1236            sample_count: 20,
1237            covariance_diagonal: vec![0.1, 0.1],
1238        };
1239        // Euclidean = 5.0 >> threshold 0.3 → drift
1240        let sig = det
1241            .compare_snapshots(&a, &b)
1242            .expect("test: compare_snapshots should succeed for valid snapshots");
1243        assert!(sig.magnitude > 0.0);
1244        assert!(!sig.affected_dimensions.is_empty());
1245    }
1246
1247    // ── KLDivergence: no drift ─────────────────────────────────────────────────
1248
1249    #[test]
1250    fn test_kl_no_drift() {
1251        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::KLDivergence(5.0)));
1252        let snap = DriftSnapshot {
1253            snapshot_id: "x".into(),
1254            timestamp: 0,
1255            centroid: vec![0.5, 0.5],
1256            variance: 1.0,
1257            sample_count: 20,
1258            covariance_diagonal: vec![1.0, 1.0],
1259        };
1260        let sig = det
1261            .compare_snapshots(&snap, &snap)
1262            .expect("test: compare_snapshots should succeed for identical snapshots");
1263        assert_eq!(sig.magnitude, 0.0);
1264    }
1265
1266    // ── KLDivergence: drift ────────────────────────────────────────────────────
1267
1268    #[test]
1269    fn test_kl_drift() {
1270        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::KLDivergence(0.01)));
1271        let a = DriftSnapshot {
1272            snapshot_id: "a".into(),
1273            timestamp: 0,
1274            centroid: vec![0.0, 0.0],
1275            variance: 0.5,
1276            sample_count: 20,
1277            covariance_diagonal: vec![0.5, 0.5],
1278        };
1279        let b = DriftSnapshot {
1280            snapshot_id: "b".into(),
1281            timestamp: 1,
1282            centroid: vec![5.0, 5.0],
1283            variance: 2.0,
1284            sample_count: 20,
1285            covariance_diagonal: vec![2.0, 2.0],
1286        };
1287        let sig = det
1288            .compare_snapshots(&a, &b)
1289            .expect("test: compare_snapshots should succeed for valid snapshots");
1290        assert!(sig.magnitude > 0.0);
1291    }
1292
1293    // ── PageHinkley: no drift ──────────────────────────────────────────────────
1294
1295    #[test]
1296    fn test_ph_no_drift() {
1297        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::PageHinkley {
1298            delta: 0.01,
1299            lambda: 100.0,
1300        }));
1301        let snap = DriftSnapshot {
1302            snapshot_id: "x".into(),
1303            timestamp: 0,
1304            centroid: vec![1.0, 1.0],
1305            variance: 0.1,
1306            sample_count: 20,
1307            covariance_diagonal: vec![0.1, 0.1],
1308        };
1309        let sig = det
1310            .compare_snapshots(&snap, &snap)
1311            .expect("test: compare_snapshots should succeed for identical snapshots");
1312        assert_eq!(sig.magnitude, 0.0);
1313    }
1314
1315    // ── PageHinkley: drift ─────────────────────────────────────────────────────
1316
1317    #[test]
1318    fn test_ph_drift() {
1319        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::PageHinkley {
1320            delta: 0.0,
1321            lambda: 0.5,
1322        }));
1323        let a = DriftSnapshot {
1324            snapshot_id: "a".into(),
1325            timestamp: 0,
1326            centroid: vec![0.0, 0.0],
1327            variance: 0.1,
1328            sample_count: 20,
1329            covariance_diagonal: vec![0.1, 0.1],
1330        };
1331        let b = DriftSnapshot {
1332            snapshot_id: "b".into(),
1333            timestamp: 1,
1334            centroid: vec![1.0, 1.0],
1335            variance: 0.1,
1336            sample_count: 20,
1337            covariance_diagonal: vec![0.1, 0.1],
1338        };
1339        let sig = det
1340            .compare_snapshots(&a, &b)
1341            .expect("test: compare_snapshots should succeed for valid snapshots");
1342        assert!(sig.magnitude > 0.0);
1343    }
1344
1345    // ── ADWIN: no drift ────────────────────────────────────────────────────────
1346
1347    #[test]
1348    fn test_adwin_no_drift() {
1349        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::ADWIN { delta: 0.01 }));
1350        let snap = DriftSnapshot {
1351            snapshot_id: "x".into(),
1352            timestamp: 0,
1353            centroid: vec![0.5, 0.5],
1354            variance: 1.0,
1355            sample_count: 20,
1356            covariance_diagonal: vec![1.0, 1.0],
1357        };
1358        let sig = det
1359            .compare_snapshots(&snap, &snap)
1360            .expect("test: compare_snapshots should succeed for identical snapshots");
1361        assert_eq!(sig.magnitude, 0.0);
1362    }
1363
1364    // ── ADWIN: drift ───────────────────────────────────────────────────────────
1365
1366    #[test]
1367    fn test_adwin_drift() {
1368        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::ADWIN { delta: 0.001 }));
1369        let a = DriftSnapshot {
1370            snapshot_id: "a".into(),
1371            timestamp: 0,
1372            centroid: vec![0.0, 0.0],
1373            variance: 0.01,
1374            sample_count: 20,
1375            covariance_diagonal: vec![0.01, 0.01],
1376        };
1377        let b = DriftSnapshot {
1378            snapshot_id: "b".into(),
1379            timestamp: 1,
1380            centroid: vec![10.0, 10.0],
1381            variance: 0.01,
1382            sample_count: 20,
1383            covariance_diagonal: vec![0.01, 0.01],
1384        };
1385        let sig = det
1386            .compare_snapshots(&a, &b)
1387            .expect("test: compare_snapshots should succeed for valid snapshots");
1388        assert!(sig.magnitude > 0.0);
1389    }
1390
1391    // ── CUSUMDetector: no drift ───────────────────────────────────────────────
1392
1393    #[test]
1394    fn test_cusum_no_drift() {
1395        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CUSUMDetector {
1396            k: 0.5,
1397            h: 100.0,
1398        }));
1399        let snap = DriftSnapshot {
1400            snapshot_id: "x".into(),
1401            timestamp: 0,
1402            centroid: vec![1.0, 1.0],
1403            variance: 1.0,
1404            sample_count: 20,
1405            covariance_diagonal: vec![1.0, 1.0],
1406        };
1407        let sig = det
1408            .compare_snapshots(&snap, &snap)
1409            .expect("test: compare_snapshots should succeed for identical snapshots");
1410        assert_eq!(sig.magnitude, 0.0);
1411    }
1412
1413    // ── CUSUMDetector: drift ──────────────────────────────────────────────────
1414
1415    #[test]
1416    fn test_cusum_drift() {
1417        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CUSUMDetector {
1418            k: 0.0,
1419            h: 0.5,
1420        }));
1421        let a = DriftSnapshot {
1422            snapshot_id: "a".into(),
1423            timestamp: 0,
1424            centroid: vec![0.0, 0.0],
1425            variance: 0.01,
1426            sample_count: 20,
1427            covariance_diagonal: vec![0.01, 0.01],
1428        };
1429        let b = DriftSnapshot {
1430            snapshot_id: "b".into(),
1431            timestamp: 1,
1432            centroid: vec![5.0, 5.0],
1433            variance: 0.01,
1434            sample_count: 20,
1435            covariance_diagonal: vec![0.01, 0.01],
1436        };
1437        let sig = det
1438            .compare_snapshots(&a, &b)
1439            .expect("test: compare_snapshots should succeed for valid snapshots");
1440        assert!(sig.magnitude > 0.0);
1441    }
1442
1443    // ── reset_reference ────────────────────────────────────────────────────────
1444
1445    #[test]
1446    fn test_reset_reference_empty_error() {
1447        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1448        assert_eq!(det.reset_reference(), Err(DetectorError::WindowEmpty));
1449    }
1450
1451    #[test]
1452    fn test_reset_reference_updates() {
1453        let cfg = DetectorConfig {
1454            window_size: 10,
1455            reference_window_size: 10,
1456            min_samples_before_detect: 100, // no detection yet
1457            ..make_config(DetectionMethod::CentroidDistance(0.3))
1458        };
1459        let mut det = EmbeddingDriftDetector::new(cfg);
1460        fill(&mut det, 0.5, 3, 5, 0);
1461        det.reset_reference()
1462            .expect("test: reset_reference should succeed after adding samples");
1463        assert_eq!(det.reference_len(), 5);
1464    }
1465
1466    // ── drift_history ──────────────────────────────────────────────────────────
1467
1468    #[test]
1469    fn test_drift_history_initially_empty() {
1470        let det = EmbeddingDriftDetector::new(DetectorConfig::default());
1471        assert!(det.drift_history().is_empty());
1472    }
1473
1474    #[test]
1475    fn test_drift_history_capped_at_100() {
1476        let cfg = DetectorConfig {
1477            method: DetectionMethod::CentroidDistance(0.001),
1478            window_size: 20,
1479            reference_window_size: 20,
1480            min_samples_before_detect: 10,
1481            drift_threshold: 0.001,
1482        };
1483        let mut det = EmbeddingDriftDetector::new(cfg);
1484        // Build a reference window.
1485        fill(&mut det, 0.1, 2, 20, 0);
1486
1487        // Now send 110 "drifted" batches (each refills a full window).
1488        // We cannot easily trigger 110 signals via add_sample because we need
1489        // a full window each time. Instead, fake it via record_drift.
1490        for i in 0..110_u64 {
1491            let sig = DriftSignal {
1492                detector_id: det.id.clone(),
1493                signal_type: DriftType::ConceptDrift,
1494                magnitude: 0.5,
1495                confidence: 0.9,
1496                detected_at: i,
1497                affected_dimensions: vec![],
1498            };
1499            det.record_drift(&sig);
1500        }
1501        assert_eq!(det.drift_history().len(), 100);
1502    }
1503
1504    // ── stats ─────────────────────────────────────────────────────────────────
1505
1506    #[test]
1507    fn test_stats_initial() {
1508        let det = EmbeddingDriftDetector::new(DetectorConfig::default());
1509        let s = det.stats();
1510        assert_eq!(s.snapshots_taken, 0);
1511        assert_eq!(s.drifts_detected, 0);
1512        assert!(s.last_drift_at.is_none());
1513    }
1514
1515    #[test]
1516    fn test_stats_incremented_on_drift() {
1517        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1518        let sig = DriftSignal {
1519            detector_id: det.id.clone(),
1520            signal_type: DriftType::SuddenDrift,
1521            magnitude: 0.8,
1522            confidence: 0.9,
1523            detected_at: 42,
1524            affected_dimensions: vec![0, 1],
1525        };
1526        det.record_drift(&sig);
1527        let s = det.stats();
1528        assert_eq!(s.drifts_detected, 1);
1529        assert_eq!(s.last_drift_at, Some(42));
1530        assert!((s.avg_drift_magnitude - 0.8).abs() < 1e-10);
1531    }
1532
1533    // ── End-to-end: add_sample triggers drift signal ──────────────────────────
1534
1535    #[test]
1536    fn test_end_to_end_no_drift() {
1537        let cfg = DetectorConfig {
1538            method: DetectionMethod::CentroidDistance(2.0),
1539            window_size: 20,
1540            reference_window_size: 20,
1541            min_samples_before_detect: 10,
1542            drift_threshold: 2.0,
1543        };
1544        let mut det = EmbeddingDriftDetector::new(cfg);
1545        let mut any_signal = false;
1546        for i in 0..40_u64 {
1547            let result = det
1548                .add_sample(constant_emb(0.1, 4), i)
1549                .expect("test: add_sample should succeed for constant embedding");
1550            if result.is_some() {
1551                any_signal = true;
1552            }
1553        }
1554        assert!(!any_signal, "constant embeddings should not trigger drift");
1555    }
1556
1557    #[test]
1558    fn test_end_to_end_drift_detected() {
1559        let cfg = DetectorConfig {
1560            method: DetectionMethod::CentroidDistance(0.1),
1561            window_size: 20,
1562            reference_window_size: 20,
1563            min_samples_before_detect: 10,
1564            drift_threshold: 0.1,
1565        };
1566        let mut det = EmbeddingDriftDetector::new(cfg);
1567
1568        // Reference phase: fill window with near-zero embeddings.
1569        fill(&mut det, 0.0, 4, 20, 0);
1570        det.reset_reference()
1571            .expect("test: reset_reference should succeed after filling the window");
1572
1573        // Drift phase: add very different embeddings until window refills.
1574        let mut drift_found = false;
1575        for i in 20..50_u64 {
1576            if let Ok(Some(_)) = det.add_sample(constant_emb(100.0, 4), i) {
1577                drift_found = true;
1578                break;
1579            }
1580        }
1581        assert!(drift_found, "large centroid shift should trigger drift");
1582    }
1583
1584    #[test]
1585    fn test_end_to_end_kl_drift() {
1586        let cfg = DetectorConfig {
1587            method: DetectionMethod::KLDivergence(0.01),
1588            window_size: 20,
1589            reference_window_size: 20,
1590            min_samples_before_detect: 10,
1591            drift_threshold: 0.01,
1592        };
1593        let mut det = EmbeddingDriftDetector::new(cfg);
1594        fill(&mut det, 0.1, 3, 20, 0);
1595        det.reset_reference()
1596            .expect("test: reset_reference should succeed after filling the window");
1597
1598        let mut drift_found = false;
1599        for i in 20..60_u64 {
1600            if let Ok(Some(_)) = det.add_sample(constant_emb(50.0, 3), i) {
1601                drift_found = true;
1602                break;
1603            }
1604        }
1605        assert!(drift_found);
1606    }
1607
1608    #[test]
1609    fn test_end_to_end_ph_drift() {
1610        let cfg = DetectorConfig {
1611            method: DetectionMethod::PageHinkley {
1612                delta: 0.0,
1613                lambda: 0.5,
1614            },
1615            window_size: 20,
1616            reference_window_size: 20,
1617            min_samples_before_detect: 10,
1618            drift_threshold: 0.5,
1619        };
1620        let mut det = EmbeddingDriftDetector::new(cfg);
1621        fill(&mut det, 0.0, 2, 20, 0);
1622        det.reset_reference()
1623            .expect("test: reset_reference should succeed after filling the window");
1624
1625        let mut drift_found = false;
1626        for i in 20..60_u64 {
1627            if let Ok(Some(_)) = det.add_sample(constant_emb(50.0, 2), i) {
1628                drift_found = true;
1629                break;
1630            }
1631        }
1632        assert!(drift_found);
1633    }
1634
1635    #[test]
1636    fn test_end_to_end_adwin_drift() {
1637        let cfg = DetectorConfig {
1638            method: DetectionMethod::ADWIN { delta: 0.001 },
1639            window_size: 20,
1640            reference_window_size: 20,
1641            min_samples_before_detect: 10,
1642            drift_threshold: 0.001,
1643        };
1644        let mut det = EmbeddingDriftDetector::new(cfg);
1645        fill(&mut det, 0.0, 2, 20, 0);
1646        det.reset_reference()
1647            .expect("test: reset_reference should succeed after filling the window");
1648
1649        let mut drift_found = false;
1650        for i in 20..60_u64 {
1651            if let Ok(Some(_)) = det.add_sample(constant_emb(50.0, 2), i) {
1652                drift_found = true;
1653                break;
1654            }
1655        }
1656        assert!(drift_found);
1657    }
1658
1659    #[test]
1660    fn test_end_to_end_cusum_drift() {
1661        let cfg = DetectorConfig {
1662            method: DetectionMethod::CUSUMDetector { k: 0.0, h: 0.5 },
1663            window_size: 20,
1664            reference_window_size: 20,
1665            min_samples_before_detect: 10,
1666            drift_threshold: 0.5,
1667        };
1668        let mut det = EmbeddingDriftDetector::new(cfg);
1669        fill(&mut det, 0.0, 2, 20, 0);
1670        det.reset_reference()
1671            .expect("test: reset_reference should succeed after filling the window");
1672
1673        let mut drift_found = false;
1674        for i in 20..60_u64 {
1675            if let Ok(Some(_)) = det.add_sample(constant_emb(50.0, 2), i) {
1676                drift_found = true;
1677                break;
1678            }
1679        }
1680        assert!(drift_found);
1681    }
1682
1683    // ── DriftType coverage ────────────────────────────────────────────────────
1684
1685    #[test]
1686    fn test_drift_type_variance() {
1687        // Trigger VarianceDrift branch via CentroidDistance with high variance ratio.
1688        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(0.01)));
1689        let a = DriftSnapshot {
1690            snapshot_id: "a".into(),
1691            timestamp: 0,
1692            centroid: vec![0.0],
1693            variance: 0.0001,
1694            sample_count: 20,
1695            covariance_diagonal: vec![0.0001],
1696        };
1697        let b = DriftSnapshot {
1698            snapshot_id: "b".into(),
1699            timestamp: 1,
1700            centroid: vec![0.1],
1701            variance: 10.0,
1702            sample_count: 20,
1703            covariance_diagonal: vec![10.0],
1704        };
1705        let sig = det
1706            .compare_snapshots(&a, &b)
1707            .expect("test: compare_snapshots should succeed for valid snapshots");
1708        if sig.magnitude > 0.0 {
1709            assert!(matches!(
1710                sig.signal_type,
1711                DriftType::VarianceDrift | DriftType::ConceptDrift
1712            ));
1713        }
1714    }
1715
1716    #[test]
1717    fn test_drift_type_sudden() {
1718        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::PageHinkley {
1719            delta: 0.0,
1720            lambda: 0.01,
1721        }));
1722        let a = DriftSnapshot {
1723            snapshot_id: "a".into(),
1724            timestamp: 0,
1725            centroid: vec![0.0],
1726            variance: 0.01,
1727            sample_count: 20,
1728            covariance_diagonal: vec![0.01],
1729        };
1730        let b = DriftSnapshot {
1731            snapshot_id: "b".into(),
1732            timestamp: 1,
1733            centroid: vec![100.0],
1734            variance: 0.01,
1735            sample_count: 20,
1736            covariance_diagonal: vec![0.01],
1737        };
1738        let sig = det
1739            .compare_snapshots(&a, &b)
1740            .expect("test: compare_snapshots should succeed for valid snapshots");
1741        if sig.magnitude > 0.6 {
1742            assert!(matches!(sig.signal_type, DriftType::SuddenDrift));
1743        }
1744    }
1745
1746    #[test]
1747    fn test_drift_type_gradual() {
1748        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::PageHinkley {
1749            delta: 0.0,
1750            lambda: 0.001,
1751        }));
1752        let a = DriftSnapshot {
1753            snapshot_id: "a".into(),
1754            timestamp: 0,
1755            centroid: vec![0.0],
1756            variance: 0.1,
1757            sample_count: 20,
1758            covariance_diagonal: vec![0.1],
1759        };
1760        let b = DriftSnapshot {
1761            snapshot_id: "b".into(),
1762            timestamp: 1,
1763            centroid: vec![0.3],
1764            variance: 0.1,
1765            sample_count: 20,
1766            covariance_diagonal: vec![0.1],
1767        };
1768        let sig = det
1769            .compare_snapshots(&a, &b)
1770            .expect("test: compare_snapshots should succeed for valid snapshots");
1771        if sig.magnitude > 0.0 && sig.magnitude <= 0.6 {
1772            assert!(matches!(sig.signal_type, DriftType::GradualDrift));
1773        }
1774    }
1775
1776    // ── Magnitude / confidence bounds ─────────────────────────────────────────
1777
1778    #[test]
1779    fn test_magnitude_in_range() {
1780        let det =
1781            EmbeddingDriftDetector::new(make_config(DetectionMethod::CentroidDistance(0.001)));
1782        let a = DriftSnapshot {
1783            snapshot_id: "a".into(),
1784            timestamp: 0,
1785            centroid: vec![0.0, 0.0, 0.0],
1786            variance: 0.01,
1787            sample_count: 20,
1788            covariance_diagonal: vec![0.01, 0.01, 0.01],
1789        };
1790        let b = DriftSnapshot {
1791            snapshot_id: "b".into(),
1792            timestamp: 1,
1793            centroid: vec![1e9, 1e9, 1e9],
1794            variance: 0.01,
1795            sample_count: 20,
1796            covariance_diagonal: vec![0.01, 0.01, 0.01],
1797        };
1798        let sig = det
1799            .compare_snapshots(&a, &b)
1800            .expect("test: compare_snapshots should succeed for valid snapshots");
1801        assert!((0.0..=1.0).contains(&sig.magnitude));
1802        assert!((0.0..=1.0).contains(&sig.confidence));
1803    }
1804
1805    // ── random_f64 ────────────────────────────────────────────────────────────
1806
1807    #[test]
1808    fn test_random_f64_range() {
1809        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1810        for _ in 0..100 {
1811            let v = det.random_f64();
1812            assert!((0.0..1.0).contains(&v));
1813        }
1814    }
1815
1816    // ── DetectorError Display ─────────────────────────────────────────────────
1817
1818    #[test]
1819    fn test_error_display_insufficient() {
1820        let e = DetectorError::InsufficientData(5);
1821        assert!(e.to_string().contains("5"));
1822    }
1823
1824    #[test]
1825    fn test_error_display_dim_mismatch() {
1826        let e = DetectorError::DimensionMismatch {
1827            expected: 3,
1828            got: 5,
1829        };
1830        let s = e.to_string();
1831        assert!(s.contains("3") && s.contains("5"));
1832    }
1833
1834    #[test]
1835    fn test_error_display_window_empty() {
1836        assert!(DetectorError::WindowEmpty.to_string().contains("empty"));
1837    }
1838
1839    #[test]
1840    fn test_error_display_config() {
1841        let e = DetectorError::ConfigurationError("bad config".into());
1842        assert!(e.to_string().contains("bad config"));
1843    }
1844
1845    // ── Snapshot from multi-dim window ────────────────────────────────────────
1846
1847    #[test]
1848    fn test_snapshot_multi_dim() {
1849        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1850        det.add_sample(vec![1.0, 10.0, 100.0], 0)
1851            .expect("test: add_sample should succeed for 3d embedding");
1852        det.add_sample(vec![3.0, 30.0, 300.0], 1)
1853            .expect("test: add_sample should succeed for 3d embedding");
1854        let snap = det
1855            .take_snapshot(2)
1856            .expect("test: take_snapshot should succeed after adding samples");
1857        assert!((snap.centroid[0] - 2.0).abs() < 1e-10);
1858        assert!((snap.centroid[1] - 20.0).abs() < 1e-10);
1859        assert!((snap.centroid[2] - 200.0).abs() < 1e-10);
1860        assert_eq!(snap.covariance_diagonal.len(), 3);
1861    }
1862
1863    // ── KL divergence with identical distributions ────────────────────────────
1864
1865    #[test]
1866    fn test_kl_identical_distributions() {
1867        let det = EmbeddingDriftDetector::new(make_config(DetectionMethod::KLDivergence(0.001)));
1868        let snap = DriftSnapshot {
1869            snapshot_id: "s".into(),
1870            timestamp: 0,
1871            centroid: vec![0.5, 0.5],
1872            variance: 1.0,
1873            sample_count: 20,
1874            covariance_diagonal: vec![1.0, 1.0],
1875        };
1876        // KL(P||P) should be ~0, so no drift.
1877        let sig = det
1878            .compare_snapshots(&snap, &snap)
1879            .expect("test: compare_snapshots should succeed for identical snapshots");
1880        assert_eq!(sig.magnitude, 0.0);
1881    }
1882
1883    // ── Snapshot sample count ─────────────────────────────────────────────────
1884
1885    #[test]
1886    fn test_snapshot_sample_count() {
1887        let mut det = EmbeddingDriftDetector::new(DetectorConfig::default());
1888        for i in 0..7_u64 {
1889            det.add_sample(vec![i as f64], i)
1890                .expect("test: add_sample should succeed for scalar embedding");
1891        }
1892        let snap = det
1893            .take_snapshot(100)
1894            .expect("test: take_snapshot should succeed after adding samples");
1895        assert_eq!(snap.sample_count, 7);
1896    }
1897
1898    // ── Reference window seed ─────────────────────────────────────────────────
1899
1900    #[test]
1901    fn test_reference_seeded_on_fill() {
1902        let cfg = DetectorConfig {
1903            window_size: 5,
1904            reference_window_size: 5,
1905            min_samples_before_detect: 100,
1906            ..make_config(DetectionMethod::CentroidDistance(0.3))
1907        };
1908        let mut det = EmbeddingDriftDetector::new(cfg);
1909        fill(&mut det, 0.0, 2, 5, 0);
1910        assert_eq!(det.reference_len(), 5);
1911    }
1912
1913    // ── Multiple detectors have distinct IDs ──────────────────────────────────
1914
1915    #[test]
1916    fn test_distinct_ids() {
1917        let d1 = EmbeddingDriftDetector::new(DetectorConfig::default());
1918        let d2 = EmbeddingDriftDetector::new(DetectorConfig::default());
1919        assert_ne!(d1.id, d2.id);
1920    }
1921
1922    // ── Signal detector_id matches ────────────────────────────────────────────
1923
1924    #[test]
1925    fn test_signal_detector_id_matches() {
1926        let cfg = DetectorConfig {
1927            method: DetectionMethod::CentroidDistance(0.001),
1928            window_size: 10,
1929            reference_window_size: 10,
1930            min_samples_before_detect: 5,
1931            drift_threshold: 0.001,
1932        };
1933        let mut det = EmbeddingDriftDetector::with_id("my-detector", cfg);
1934        fill(&mut det, 0.0, 2, 10, 0);
1935        det.reset_reference()
1936            .expect("test: reset_reference should succeed after filling the window");
1937
1938        for i in 10..30_u64 {
1939            if let Ok(Some(sig)) = det.add_sample(constant_emb(100.0, 2), i) {
1940                assert_eq!(sig.detector_id, "my-detector");
1941                break;
1942            }
1943        }
1944    }
1945
1946    // ── DriftStats false_positive_estimate ────────────────────────────────────
1947
1948    #[test]
1949    fn test_false_positive_estimate_low_mag() {
1950        let mut det = EmbeddingDriftDetector::new(DetectorConfig {
1951            drift_threshold: 0.5,
1952            ..DetectorConfig::default()
1953        });
1954        // Add many low-magnitude signals.
1955        for i in 0..10_u64 {
1956            let sig = DriftSignal {
1957                detector_id: det.id.clone(),
1958                signal_type: DriftType::ConceptDrift,
1959                magnitude: 0.1, // below threshold/2 = 0.25
1960                confidence: 0.5,
1961                detected_at: i,
1962                affected_dimensions: vec![],
1963            };
1964            det.record_drift(&sig);
1965        }
1966        // All signals are low-magnitude → FP estimate ≈ 1.0
1967        assert!(det.stats().false_positive_estimate > 0.5);
1968    }
1969}