1use std::collections::VecDeque;
43use std::fmt;
44
45#[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#[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
67fn 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
83fn 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#[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#[derive(Debug, Clone, PartialEq)]
116pub enum DetectorError {
117 InsufficientData(usize),
119 DimensionMismatch {
121 expected: usize,
123 got: usize,
125 },
126 WindowEmpty,
128 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#[derive(Debug, Clone, PartialEq)]
151pub enum DetectionMethod {
152 CentroidDistance(f64),
154 KLDivergence(f64),
156 PageHinkley { delta: f64, lambda: f64 },
159 ADWIN { delta: f64 },
162 CUSUMDetector { k: f64, h: f64 },
165}
166
167#[derive(Debug, Clone, PartialEq)]
171pub enum DriftType {
172 ConceptDrift,
174 VarianceDrift,
176 DimensionDrift {
178 dim: usize,
180 },
181 SeasonalDrift,
183 GradualDrift,
185 SuddenDrift,
187 RecurringDrift,
189}
190
191#[derive(Debug, Clone)]
195pub struct DriftSnapshot {
196 pub snapshot_id: String,
198 pub timestamp: u64,
200 pub centroid: Vec<f64>,
202 pub variance: f64,
204 pub sample_count: usize,
206 pub covariance_diagonal: Vec<f64>,
208}
209
210#[derive(Debug, Clone)]
212pub struct DriftSignal {
213 pub detector_id: String,
215 pub signal_type: DriftType,
217 pub magnitude: f64,
219 pub confidence: f64,
221 pub detected_at: u64,
223 pub affected_dimensions: Vec<usize>,
225}
226
227#[derive(Debug, Clone)]
231pub struct DetectorConfig {
232 pub method: DetectionMethod,
234 pub window_size: usize,
236 pub reference_window_size: usize,
238 pub min_samples_before_detect: usize,
240 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#[derive(Debug, Clone, Default)]
260pub struct DriftStats {
261 pub snapshots_taken: usize,
263 pub drifts_detected: usize,
265 pub false_positive_estimate: f64,
267 pub avg_drift_magnitude: f64,
269 pub last_drift_at: Option<u64>,
271}
272
273#[derive(Debug, Clone, Default)]
276struct PageHinkleyState {
277 cumsum_pos: f64,
278 cumsum_neg: f64,
279 running_mean: f64,
280 n: usize,
281}
282
283#[derive(Debug, Clone, Default)]
286struct CusumState {
287 cumsum_pos: f64,
288 cumsum_neg: f64,
289 running_mean: f64,
290 n: usize,
291}
292
293pub struct EmbeddingDriftDetector {
301 pub id: String,
303 pub config: DetectorConfig,
305
306 rolling: VecDeque<Vec<f64>>,
308 reference: VecDeque<Vec<f64>>,
310 dim: Option<usize>,
312
313 ph_state: PageHinkleyState,
315 cusum_state: CusumState,
316
317 rng_state: u64,
319
320 history: VecDeque<DriftSignal>,
322
323 stats: DriftStats,
325
326 magnitude_sum: f64,
328
329 snapshot_counter: u64,
331}
332
333impl EmbeddingDriftDetector {
334 pub fn new(config: DetectorConfig) -> Self {
336 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, history: VecDeque::new(),
351 stats: DriftStats::default(),
352 magnitude_sum: 0.0,
353 snapshot_counter: 0,
354 }
355 }
356
357 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 pub fn add_sample(
370 &mut self,
371 embedding: Vec<f64>,
372 timestamp: u64,
373 ) -> Result<Option<DriftSignal>, DetectorError> {
374 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 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 self.rolling.push_back(embedding);
400 if self.rolling.len() > self.config.window_size {
401 self.rolling.pop_front();
402 }
403
404 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 if self.rolling.len() < self.config.min_samples_before_detect || self.reference.is_empty() {
413 return Ok(None);
414 }
415
416 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 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 pub fn take_snapshot(&mut self, timestamp: u64) -> Result<DriftSnapshot, DetectorError> {
438 self.snapshot_from_window(&self.rolling.clone(), timestamp)
439 }
440
441 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 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 self.ph_state = PageHinkleyState::default();
488 self.cusum_state = CusumState::default();
489 Ok(())
490 }
491
492 pub fn drift_history(&self) -> Vec<DriftSignal> {
494 self.history.iter().cloned().collect()
495 }
496
497 pub fn stats(&self) -> DriftStats {
499 self.stats.clone()
500 }
501
502 pub fn window_len(&self) -> usize {
504 self.rolling.len()
505 }
506
507 pub fn reference_len(&self) -> usize {
509 self.reference.len()
510 }
511
512 pub fn embedding_dim(&self) -> Option<usize> {
514 self.dim
515 }
516
517 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 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 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 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 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 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, ¤t.centroid);
629 let cos_dist = cosine_distance(&reference.centroid, ¤t.centroid);
631 let ts = current.timestamp;
632
633 if dist <= threshold {
634 return Ok(self.no_drift_signal(ts));
635 }
636
637 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 let confidence = (1.0 - (-magnitude * 3.0).exp()) * (0.8 + 0.2 * cos_dist).min(1.0);
651
652 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 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); 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 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 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 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, ¤t.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 let diff_norm = euclidean_distance(&reference.centroid, ¤t.centroid);
818 let target = reference.variance.sqrt() + 1e-8; 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 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 fn update_ph_state(&mut self, norm: f64) {
845 let state = &mut self.ph_state;
846 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; 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 pub fn random_f64(&mut self) -> f64 {
867 xorshift_f64(&mut self.rng_state)
868 }
869}
870
871#[cfg(test)]
874mod tests {
875 use super::*;
876
877 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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, ..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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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, ..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 #[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 fill(&mut det, 0.1, 2, 20, 0);
1486
1487 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 #[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 #[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 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 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 #[test]
1686 fn test_drift_type_variance() {
1687 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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, confidence: 0.5,
1961 detected_at: i,
1962 affected_dimensions: vec![],
1963 };
1964 det.record_drift(&sig);
1965 }
1966 assert!(det.stats().false_positive_estimate > 0.5);
1968 }
1969}