1use super::config::*;
8use super::drift_models::{
9 DecisionTreeDriftDetector, EnsembleDriftDetector, NeuralNetworkDriftDetector,
10};
11use super::drift_tests::{
12 AdwinTest, CusumTest, DdmTest, EddmTest, HistogramComparator, HistogramDivergence, KsTest,
13 LinearModelDetector, MannWhitneyUTest, PageHinkleyTest, WassersteinComparator,
14};
15use super::optimizer::{Adaptation, AdaptationPriority, AdaptationType, StreamingDataPoint};
16
17use crate::utils::{scalar_or, try_scalar_str};
18use scirs2_core::numeric::Float;
19use std::collections::{HashMap, VecDeque};
20use std::time::{Duration, Instant};
21
22pub struct EnhancedDriftDetector<A: Float + Send + Sync> {
24 config: DriftConfig,
26 detection_method: DriftDetectionMethod,
28 statistical_tests: HashMap<StatisticalMethod, Box<dyn StatisticalTest<A>>>,
30 distribution_methods: HashMap<DistributionMethod, Box<dyn DistributionComparator<A>>>,
32 model_detectors: HashMap<ModelType, Box<dyn ModelBasedDetector<A>>>,
34 detection_history: VecDeque<DriftEvent<A>>,
37 false_positive_tracker: FalsePositiveTracker<A>,
39 reference_window: VecDeque<StreamingDataPoint<A>>,
41 drift_state: DriftState,
43 last_detection: Option<Instant>,
45 sensitivity_factor: A,
47}
48
49#[derive(Debug, Clone)]
51pub struct DriftEvent<A: Float + Send + Sync> {
52 pub timestamp: Instant,
54 pub severity: DriftSeverity,
56 pub confidence: A,
58 pub detection_method: String,
60 pub p_value: Option<A>,
62 pub magnitude: A,
64 pub affected_features: Vec<usize>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
70pub enum DriftSeverity {
71 Minor,
73 Moderate,
75 Major,
77 Critical,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum DriftState {
84 Stable,
86 Warning,
88 Drift,
90 Recovery,
92}
93
94pub struct FalsePositiveTracker<A: Float + Send + Sync> {
96 false_positives: VecDeque<Instant>,
98 true_positives: VecDeque<Instant>,
100 current_fp_rate: A,
102 target_fp_rate: A,
104}
105
106pub trait StatisticalTest<A: Float + Send + Sync>: Send + Sync {
108 fn test_for_drift(
110 &mut self,
111 reference: &[A],
112 current: &[A],
113 ) -> Result<DriftTestResult<A>, String>;
114
115 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String>;
117
118 fn reset(&mut self);
120}
121
122#[derive(Debug, Clone)]
124pub struct DriftTestResult<A: Float + Send + Sync> {
125 pub drift_detected: bool,
127 pub p_value: A,
129 pub test_statistic: A,
131 pub confidence: A,
133 pub metadata: HashMap<String, A>,
135}
136
137pub trait DistributionComparator<A: Float + Send + Sync>: Send + Sync {
139 fn compare_distributions(
141 &self,
142 reference: &[A],
143 current: &[A],
144 ) -> Result<DistributionComparison<A>, String>;
145
146 fn get_threshold(&self) -> A;
148
149 fn update_threshold(&mut self, new_threshold: A);
151}
152
153#[derive(Debug, Clone)]
155pub struct DistributionComparison<A: Float + Send + Sync> {
156 pub distance: A,
158 pub threshold: A,
160 pub drift_detected: bool,
162 pub confidence: A,
164}
165
166pub trait ModelBasedDetector<A: Float + Send + Sync>: Send + Sync {
168 fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String>;
170
171 fn detect_drift(
173 &mut self,
174 data: &[StreamingDataPoint<A>],
175 ) -> Result<ModelDriftResult<A>, String>;
176
177 fn reset_model(&mut self) -> Result<(), String>;
179}
180
181#[derive(Debug, Clone)]
183pub struct ModelDriftResult<A: Float + Send + Sync> {
184 pub drift_detected: bool,
186 pub performance_degradation: A,
188 pub confidence: A,
190 pub feature_importance_changes: Vec<A>,
192}
193
194impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum + 'static> EnhancedDriftDetector<A> {
195 pub fn new(config: &StreamingConfig) -> Result<Self, String> {
197 let drift_config = config.drift_config.clone();
198
199 let mut statistical_tests: HashMap<StatisticalMethod, Box<dyn StatisticalTest<A>>> =
200 HashMap::new();
201 let mut distribution_methods: HashMap<
202 DistributionMethod,
203 Box<dyn DistributionComparator<A>>,
204 > = HashMap::new();
205 let mut model_detectors: HashMap<ModelType, Box<dyn ModelBasedDetector<A>>> =
206 HashMap::new();
207
208 let sensitivity = drift_config.sensitivity;
209 let alpha = drift_config.significance_level;
210
211 statistical_tests.insert(
217 StatisticalMethod::ADWIN,
218 Box::new(AdwinTest::new(sensitivity, alpha)?),
219 );
220 statistical_tests.insert(
221 StatisticalMethod::DDM,
222 Box::new(DdmTest::new(sensitivity, alpha)?),
223 );
224 statistical_tests.insert(
225 StatisticalMethod::EDDM,
226 Box::new(EddmTest::new(sensitivity, alpha)?),
227 );
228 statistical_tests.insert(
229 StatisticalMethod::PageHinkley,
230 Box::new(PageHinkleyTest::new(sensitivity, alpha)?),
231 );
232 statistical_tests.insert(
233 StatisticalMethod::CUSUM,
234 Box::new(CusumTest::new(sensitivity, alpha)?),
235 );
236 statistical_tests.insert(
237 StatisticalMethod::KolmogorovSmirnov,
238 Box::new(KsTest::new(sensitivity, alpha)?),
239 );
240 statistical_tests.insert(
241 StatisticalMethod::MannWhitneyU,
242 Box::new(MannWhitneyUTest::new(sensitivity, alpha)?),
243 );
244
245 distribution_methods.insert(
247 DistributionMethod::KLDivergence,
248 Box::new(HistogramComparator::new(
249 HistogramDivergence::KullbackLeibler,
250 sensitivity,
251 )?),
252 );
253 distribution_methods.insert(
254 DistributionMethod::JSDivergence,
255 Box::new(HistogramComparator::new(
256 HistogramDivergence::JensenShannon,
257 sensitivity,
258 )?),
259 );
260 distribution_methods.insert(
261 DistributionMethod::HellingerDistance,
262 Box::new(HistogramComparator::new(
263 HistogramDivergence::Hellinger,
264 sensitivity,
265 )?),
266 );
267 distribution_methods.insert(
271 DistributionMethod::WassersteinDistance,
272 Box::new(WassersteinComparator::new(sensitivity)?),
273 );
274 distribution_methods.insert(
275 DistributionMethod::EarthMoverDistance,
276 Box::new(WassersteinComparator::new(sensitivity)?),
277 );
278
279 model_detectors.insert(
284 ModelType::Linear,
285 Box::new(LinearModelDetector::new(sensitivity)?),
286 );
287 model_detectors.insert(
288 ModelType::NeuralNetwork,
289 Box::new(NeuralNetworkDriftDetector::new(sensitivity)?),
290 );
291 model_detectors.insert(
292 ModelType::DecisionTree,
293 Box::new(DecisionTreeDriftDetector::new(sensitivity)?),
294 );
295 model_detectors.insert(
296 ModelType::Ensemble,
297 Box::new(EnsembleDriftDetector::new(sensitivity)?),
298 );
299
300 let false_positive_tracker = FalsePositiveTracker::new();
301
302 Ok(Self {
303 config: drift_config.clone(),
304 detection_method: drift_config.detection_method,
305 statistical_tests,
306 distribution_methods,
307 model_detectors,
308 detection_history: VecDeque::with_capacity(1000),
309 false_positive_tracker,
310 reference_window: VecDeque::with_capacity(drift_config.window_size),
311 drift_state: DriftState::Stable,
312 last_detection: None,
313 sensitivity_factor: A::one(),
314 })
315 }
316
317 pub fn detect_drift(&mut self, batch: &[StreamingDataPoint<A>]) -> Result<bool, String> {
319 if !self.config.enable_detection || batch.len() < self.config.min_samples {
320 return Ok(false);
321 }
322
323 self.update_reference_window(batch)?;
325
326 if self.reference_window.len() < self.config.window_size / 2 {
328 return Ok(false);
329 }
330
331 let current_features = self.extract_features(batch)?;
333 let reference_features = self.extract_reference_features()?;
334
335 let detection_method = self.detection_method.clone();
337 let drift_result = match detection_method {
338 DriftDetectionMethod::Statistical(method) => {
339 self.detect_statistical_drift(&method, &reference_features, ¤t_features)?
340 }
341 DriftDetectionMethod::Distribution(method) => {
342 self.detect_distribution_drift(&method, &reference_features, ¤t_features)?
343 }
344 DriftDetectionMethod::ModelBased(model_type) => {
345 self.detect_model_drift(&model_type, batch)?
346 }
347 DriftDetectionMethod::Ensemble {
348 methods,
349 voting_strategy,
350 } => self.detect_ensemble_drift(
351 &methods,
352 &voting_strategy,
353 &reference_features,
354 ¤t_features,
355 batch,
356 )?,
357 };
358
359 if drift_result.drift_detected {
361 self.handle_drift_detection(drift_result)?;
362 Ok(true)
363 } else {
364 self.update_drift_state(false);
365 Ok(false)
366 }
367 }
368
369 fn update_reference_window(&mut self, batch: &[StreamingDataPoint<A>]) -> Result<(), String> {
371 for data_point in batch {
372 if self.reference_window.len() >= self.config.window_size {
373 self.reference_window.pop_front();
374 }
375 self.reference_window.push_back(data_point.clone());
376 }
377 Ok(())
378 }
379
380 fn extract_features(&self, batch: &[StreamingDataPoint<A>]) -> Result<Vec<A>, String> {
382 let mut features = Vec::new();
383
384 for data_point in batch {
385 features.extend(data_point.features.iter().cloned());
386 }
387
388 Ok(features)
389 }
390
391 fn extract_reference_features(&self) -> Result<Vec<A>, String> {
393 let reference_data: Vec<_> = self
394 .reference_window
395 .iter()
396 .take(self.reference_window.len() / 2)
397 .collect();
398
399 let mut features = Vec::new();
400 for data_point in reference_data {
401 features.extend(data_point.features.iter().cloned());
402 }
403
404 Ok(features)
405 }
406
407 fn detect_statistical_drift(
409 &mut self,
410 method: &StatisticalMethod,
411 reference: &[A],
412 current: &[A],
413 ) -> Result<DriftTestResult<A>, String> {
414 if let Some(test) = self.statistical_tests.get_mut(method) {
415 let mut result = test.test_for_drift(reference, current)?;
416
417 let alpha = A::from(self.config.significance_level).ok_or_else(|| {
427 format!(
428 "significance level {} cannot be represented in the element type",
429 self.config.significance_level
430 )
431 })?;
432 let effective_alpha = alpha * self.sensitivity_factor;
433 result.drift_detected = result.drift_detected || result.p_value < effective_alpha;
434 result.confidence = (result.confidence * self.sensitivity_factor).min(A::one());
435
436 Ok(result)
437 } else {
438 Err(format!(
439 "no statistical drift test is registered for {method:?}; \
440 substituting a different statistic would misreport which \
441 test produced the verdict"
442 ))
443 }
444 }
445
446 fn detect_distribution_drift(
448 &mut self,
449 method: &DistributionMethod,
450 reference: &[A],
451 current: &[A],
452 ) -> Result<DriftTestResult<A>, String> {
453 if let Some(comparator) = self.distribution_methods.get(method) {
454 let comparison = comparator.compare_distributions(reference, current)?;
455
456 let p_value = (A::one() - comparison.confidence)
463 .max(A::zero())
464 .min(A::one());
465 let alpha = A::from(self.config.significance_level).ok_or_else(|| {
466 format!(
467 "significance level {} cannot be represented in the element type",
468 self.config.significance_level
469 )
470 })?;
471
472 let mut metadata = HashMap::new();
473 metadata.insert("distance".to_string(), comparison.distance);
474 metadata.insert("threshold".to_string(), comparison.threshold);
475
476 let result = DriftTestResult {
477 drift_detected: comparison.drift_detected
478 || p_value < alpha * self.sensitivity_factor,
479 p_value,
480 test_statistic: comparison.distance,
481 confidence: (comparison.confidence * self.sensitivity_factor).min(A::one()),
482 metadata,
483 };
484
485 Ok(result)
486 } else {
487 Err(format!(
488 "no distribution comparator is registered for {method:?}; \
489 substituting a different divergence would misreport which \
490 measure produced the verdict"
491 ))
492 }
493 }
494
495 fn detect_model_drift(
497 &mut self,
498 model_type: &ModelType,
499 batch: &[StreamingDataPoint<A>],
500 ) -> Result<DriftTestResult<A>, String> {
501 if let Some(detector) = self.model_detectors.get_mut(model_type) {
502 let model_result = detector.detect_drift(batch)?;
503
504 let p_value = (A::one() - model_result.confidence)
507 .max(A::zero())
508 .min(A::one());
509 let alpha = A::from(self.config.significance_level).ok_or_else(|| {
510 format!(
511 "significance level {} cannot be represented in the element type",
512 self.config.significance_level
513 )
514 })?;
515
516 let mut metadata = HashMap::new();
517 metadata.insert(
518 "performance_degradation".to_string(),
519 model_result.performance_degradation,
520 );
521 for (index, change) in model_result.feature_importance_changes.iter().enumerate() {
522 metadata.insert(format!("weight_delta_{index}"), *change);
523 }
524
525 let result = DriftTestResult {
526 drift_detected: model_result.drift_detected
527 || p_value < alpha * self.sensitivity_factor,
528 p_value,
529 test_statistic: model_result.performance_degradation,
530 confidence: (model_result.confidence * self.sensitivity_factor).min(A::one()),
531 metadata,
532 };
533
534 Ok(result)
535 } else {
536 Err(format!(
537 "no model-based drift detector is registered for {model_type:?}; \
538 a feature-mean proxy is not the model-performance signal this \
539 method is defined over"
540 ))
541 }
542 }
543
544 fn detect_ensemble_drift(
546 &mut self,
547 methods: &[DriftDetectionMethod],
548 voting_strategy: &VotingStrategy,
549 reference: &[A],
550 current: &[A],
551 batch: &[StreamingDataPoint<A>],
552 ) -> Result<DriftTestResult<A>, String> {
553 let mut results = Vec::new();
554
555 for method in methods {
557 let result = match method {
558 DriftDetectionMethod::Statistical(stat_method) => {
559 self.detect_statistical_drift(stat_method, reference, current)?
560 }
561 DriftDetectionMethod::Distribution(dist_method) => {
562 self.detect_distribution_drift(dist_method, reference, current)?
563 }
564 DriftDetectionMethod::ModelBased(model_type) => {
565 self.detect_model_drift(model_type, batch)?
566 }
567 DriftDetectionMethod::Ensemble { .. } => {
568 continue;
570 }
571 };
572 results.push(result);
573 }
574
575 let ensemble_result = self.apply_voting_strategy(voting_strategy, &results)?;
577 Ok(ensemble_result)
578 }
579
580 fn apply_voting_strategy(
582 &self,
583 strategy: &VotingStrategy,
584 results: &[DriftTestResult<A>],
585 ) -> Result<DriftTestResult<A>, String> {
586 if results.is_empty() {
587 return Err("No results to vote on".to_string());
588 }
589
590 let drift_detected = match strategy {
591 VotingStrategy::Majority => {
592 let positive_votes = results.iter().filter(|r| r.drift_detected).count();
593 positive_votes > results.len() / 2
594 }
595 VotingStrategy::Weighted { weights } => {
596 if weights.len() != results.len() {
597 return Err("Number of weights doesn't match number of results".to_string());
598 }
599
600 let weighted_score: f64 = results
601 .iter()
602 .zip(weights.iter())
603 .map(|(result, &weight)| weight * if result.drift_detected { 1.0 } else { 0.0 })
604 .sum();
605
606 let total_weight: f64 = weights.iter().sum();
607 weighted_score / total_weight > 0.5
608 }
609 VotingStrategy::Unanimous => results.iter().all(|r| r.drift_detected),
610 VotingStrategy::Threshold { min_votes } => {
611 let positive_votes = results.iter().filter(|r| r.drift_detected).count();
612 positive_votes >= *min_votes
613 }
614 };
615
616 let count = A::from(results.len()).ok_or_else(|| {
619 format!(
620 "result count {} is not representable in the element type",
621 results.len()
622 )
623 })?;
624
625 let avg_confidence = results.iter().map(|r| r.confidence).sum::<A>() / count;
626 let avg_p_value = results.iter().map(|r| r.p_value).sum::<A>() / count;
627 let avg_test_statistic = results.iter().map(|r| r.test_statistic).sum::<A>() / count;
628
629 Ok(DriftTestResult {
630 drift_detected,
631 p_value: avg_p_value,
632 test_statistic: avg_test_statistic,
633 confidence: avg_confidence,
634 metadata: HashMap::new(),
635 })
636 }
637
638 fn handle_drift_detection(&mut self, result: DriftTestResult<A>) -> Result<(), String> {
640 let severity = self.classify_drift_severity(&result);
641
642 let drift_event = DriftEvent {
643 timestamp: Instant::now(),
644 severity: severity.clone(),
645 confidence: result.confidence,
646 detection_method: format!("{:?}", self.detection_method),
647 p_value: Some(result.p_value),
648 magnitude: result.test_statistic,
649 affected_features: Vec::new(), };
651
652 if self.detection_history.len() >= 1000 {
654 self.detection_history.pop_front();
655 }
656 self.detection_history.push_back(drift_event);
657
658 self.update_drift_state(true);
660 self.last_detection = Some(Instant::now());
661
662 if self.config.enable_false_positive_tracking {
664 self.false_positive_tracker.record_detection(true)?;
665 }
666
667 Ok(())
668 }
669
670 #[cfg(test)]
674 pub(crate) fn unregister_model_detector_for_test(
675 &mut self,
676 model_type: &ModelType,
677 ) -> Option<Box<dyn ModelBasedDetector<A>>> {
678 self.model_detectors.remove(model_type)
679 }
680
681 #[cfg(test)]
684 pub(crate) fn classify_drift_severity_for_test(
685 &self,
686 result: &DriftTestResult<A>,
687 ) -> DriftSeverity {
688 self.classify_drift_severity(result)
689 }
690
691 fn classify_drift_severity(&self, result: &DriftTestResult<A>) -> DriftSeverity {
692 let confidence = result.confidence.to_f64().unwrap_or(0.0);
693 let p_value = result.p_value.to_f64().unwrap_or(1.0);
694
695 let by_significance = if p_value < 0.001 && confidence > 0.95 {
697 DriftSeverity::Critical
698 } else if p_value < 0.01 && confidence > 0.9 {
699 DriftSeverity::Major
700 } else if p_value < 0.05 && confidence > 0.8 {
701 DriftSeverity::Moderate
702 } else {
703 DriftSeverity::Minor
704 };
705
706 let statistic = result.test_statistic.to_f64().unwrap_or(0.0).abs();
712 let by_magnitude = if statistic >= self.config.drift_threshold {
713 DriftSeverity::Major
714 } else if statistic >= self.config.warning_threshold {
715 DriftSeverity::Moderate
716 } else {
717 DriftSeverity::Minor
718 };
719
720 by_significance.max(by_magnitude)
724 }
725
726 fn update_drift_state(&mut self, drift_detected: bool) {
728 self.drift_state = match (&self.drift_state, drift_detected) {
729 (DriftState::Stable, true) => DriftState::Warning,
730 (DriftState::Warning, true) => DriftState::Drift,
731 (DriftState::Drift, false) => DriftState::Recovery,
732 (DriftState::Recovery, false) => DriftState::Stable,
733 (state, _) => state.clone(),
734 };
735 }
736
737 pub fn compute_sensitivity_adaptation(&mut self) -> Result<Option<Adaptation<A>>, String> {
739 if self.config.enable_false_positive_tracking {
741 let current_fp_rate = self.false_positive_tracker.current_fp_rate;
742 let target_fp_rate = self.false_positive_tracker.target_fp_rate;
746 let tolerance = scalar_or(0.02, A::zero());
747 let step = scalar_or(0.1, A::zero());
748
749 if (current_fp_rate - target_fp_rate).abs() > tolerance {
750 let adjustment = if current_fp_rate > target_fp_rate {
751 -step
753 } else {
754 step
756 };
757
758 let adaptation = Adaptation {
759 adaptation_type: AdaptationType::DriftSensitivity,
760 magnitude: adjustment,
761 target_component: "drift_detector".to_string(),
762 parameters: HashMap::new(),
763 priority: AdaptationPriority::Normal,
764 timestamp: Instant::now(),
765 };
766
767 return Ok(Some(adaptation));
768 }
769 }
770
771 Ok(None)
772 }
773
774 pub fn apply_sensitivity_adaptation(
776 &mut self,
777 adaptation: &Adaptation<A>,
778 ) -> Result<(), String> {
779 if adaptation.adaptation_type == AdaptationType::DriftSensitivity {
780 self.sensitivity_factor = (self.sensitivity_factor + adaptation.magnitude)
781 .max(try_scalar_str::<A, _>(0.1)?)
782 .min(try_scalar_str::<A, _>(2.0)?);
783 }
784 Ok(())
785 }
786
787 pub fn is_drift_detected(&self) -> bool {
789 matches!(self.drift_state, DriftState::Drift | DriftState::Warning)
790 }
791
792 pub fn get_drift_state(&self) -> &DriftState {
794 &self.drift_state
795 }
796
797 pub fn get_recent_drift_events(&self, count: usize) -> Vec<&DriftEvent<A>> {
799 self.detection_history.iter().rev().take(count).collect()
800 }
801
802 pub fn reset(&mut self) -> Result<(), String> {
804 self.detection_history.clear();
805 self.reference_window.clear();
806 self.drift_state = DriftState::Stable;
807 self.last_detection = None;
808 self.sensitivity_factor = A::one();
809
810 for test in self.statistical_tests.values_mut() {
812 test.reset();
813 }
814
815 for detector in self.model_detectors.values_mut() {
816 detector.reset_model()?;
817 }
818
819 Ok(())
820 }
821
822 pub fn get_diagnostics(&self) -> DriftDiagnostics {
824 DriftDiagnostics {
825 current_state: self.drift_state.clone(),
826 detection_count: self.detection_history.len(),
827 false_positive_rate: self
828 .false_positive_tracker
829 .current_fp_rate
830 .to_f64()
831 .unwrap_or(0.0),
832 sensitivity_factor: self.sensitivity_factor.to_f64().unwrap_or(1.0),
833 last_detection_time: self.last_detection,
834 reference_window_size: self.reference_window.len(),
835 }
836 }
837}
838
839impl<A: Float + Send + Sync + Send + Sync> FalsePositiveTracker<A> {
840 fn new() -> Self {
841 Self {
842 false_positives: VecDeque::new(),
843 true_positives: VecDeque::new(),
844 current_fp_rate: A::zero(),
845 target_fp_rate: scalar_or(0.05, A::zero()),
846 }
847 }
848
849 fn record_detection(&mut self, is_true_positive: bool) -> Result<(), String> {
850 let now = Instant::now();
851
852 if is_true_positive {
853 self.true_positives.push_back(now);
854 } else {
855 self.false_positives.push_back(now);
856 }
857
858 let retention = Duration::from_secs(3600);
864 self.false_positives
865 .retain(|&time| now.duration_since(time) <= retention);
866 self.true_positives
867 .retain(|&time| now.duration_since(time) <= retention);
868
869 let total_detections = self.false_positives.len() + self.true_positives.len();
871 if total_detections > 0 {
872 self.current_fp_rate = try_scalar_str::<A, _>(self.false_positives.len())?
873 / try_scalar_str::<A, _>(total_detections)?;
874 }
875
876 Ok(())
877 }
878}
879
880#[derive(Debug, Clone)]
882pub struct DriftDiagnostics {
883 pub current_state: DriftState,
884 pub detection_count: usize,
885 pub false_positive_rate: f64,
886 pub sensitivity_factor: f64,
887 pub last_detection_time: Option<Instant>,
888 pub reference_window_size: usize,
889}
890
891#[cfg(test)]
892mod drift_detector_regression_tests {
893 use super::*;
894 use scirs2_core::ndarray::Array1;
895
896 fn detector_with(method: DriftDetectionMethod) -> EnhancedDriftDetector<f64> {
897 let mut config = StreamingConfig::default();
898 config.drift_config.detection_method = method;
899 config.drift_config.min_samples = 10;
900 config.drift_config.window_size = 200;
901 EnhancedDriftDetector::new(&config).expect("drift detector")
902 }
903
904 fn wobble(index: usize) -> f64 {
905 ((index as f64) * 0.7548776662).fract() - 0.5
906 }
907
908 fn batch(level: f64, count: usize, offset: usize) -> Vec<StreamingDataPoint<f64>> {
909 (0..count)
910 .map(|i| StreamingDataPoint {
911 features: Array1::from_vec(vec![level + wobble(i + offset)]),
912 target: Some(Array1::from_vec(vec![level])),
913 timestamp: Instant::now(),
914 source_id: None,
915 quality_score: 1.0,
916 metadata: HashMap::new(),
917 })
918 .collect()
919 }
920
921 #[test]
926 fn every_statistical_method_is_registered() {
927 for method in [
928 StatisticalMethod::ADWIN,
929 StatisticalMethod::DDM,
930 StatisticalMethod::EDDM,
931 StatisticalMethod::PageHinkley,
932 StatisticalMethod::CUSUM,
933 StatisticalMethod::KolmogorovSmirnov,
934 StatisticalMethod::MannWhitneyU,
935 ] {
936 let mut detector = detector_with(DriftDetectionMethod::Statistical(method.clone()));
937 detector.detect_drift(&batch(10.0, 120, 0)).expect("warmup");
939 let result = detector.detect_drift(&batch(40.0, 120, 500));
940 assert!(
941 result.is_ok(),
942 "{method:?} failed on a genuine mean shift: {result:?}"
943 );
944 }
945 }
946
947 #[test]
951 fn every_distribution_method_is_registered() {
952 for method in [
953 DistributionMethod::KLDivergence,
954 DistributionMethod::JSDivergence,
955 DistributionMethod::HellingerDistance,
956 DistributionMethod::WassersteinDistance,
957 DistributionMethod::EarthMoverDistance,
958 ] {
959 let mut detector = detector_with(DriftDetectionMethod::Distribution(method.clone()));
960 detector.detect_drift(&batch(10.0, 120, 0)).expect("warmup");
961 let result = detector.detect_drift(&batch(40.0, 120, 500));
962 assert!(
963 result.is_ok(),
964 "{method:?} failed on a genuine distribution shift: {result:?}"
965 );
966 }
967 }
968
969 #[test]
980 fn every_model_type_is_registered() {
981 for model_type in [
982 ModelType::Linear,
983 ModelType::NeuralNetwork,
984 ModelType::DecisionTree,
985 ModelType::Ensemble,
986 ] {
987 let mut detector = detector_with(DriftDetectionMethod::ModelBased(model_type.clone()));
988 detector
989 .detect_drift(&batch(10.0, 120, 0))
990 .unwrap_or_else(|error| panic!("{model_type:?} warmup failed: {error}"));
991 let result = detector.detect_drift(&batch(40.0, 120, 500));
992 assert!(
993 result.is_ok(),
994 "{model_type:?} failed on a genuine mean shift: {result:?}"
995 );
996 }
997 }
998
999 #[test]
1003 fn model_type_without_a_registered_detector_is_an_honest_error() {
1004 let mut detector = detector_with(DriftDetectionMethod::ModelBased(ModelType::Linear));
1005 detector
1006 .unregister_model_detector_for_test(&ModelType::Linear)
1007 .expect("Linear starts out registered");
1008 detector.detect_drift(&batch(10.0, 120, 0)).ok();
1009 let result = detector.detect_drift(&batch(40.0, 120, 500));
1010 assert!(
1011 result.is_err(),
1012 "an unregistered model type must report an error instead of a \
1013 feature-mean proxy dressed up as a model-drift verdict"
1014 );
1015 }
1016
1017 #[test]
1021 fn implemented_model_types_fire_on_a_target_shift() {
1022 for model_type in [
1023 ModelType::NeuralNetwork,
1024 ModelType::DecisionTree,
1025 ModelType::Ensemble,
1026 ] {
1027 let mut detector = detector_with(DriftDetectionMethod::ModelBased(model_type.clone()));
1028 for round in 0..6 {
1029 detector
1030 .detect_drift(&batch(10.0, 60, round * 60))
1031 .unwrap_or_else(|error| panic!("{model_type:?} warmup failed: {error}"));
1032 }
1033 let mut fired = false;
1034 for round in 0..6 {
1035 if detector
1036 .detect_drift(&batch(400.0, 60, 5_000 + round * 60))
1037 .unwrap_or_else(|error| panic!("{model_type:?} shift failed: {error}"))
1038 {
1039 fired = true;
1040 break;
1041 }
1042 }
1043 assert!(
1044 fired,
1045 "{model_type:?} did not report drift after a 390-unit shift in the \
1046 target relationship"
1047 );
1048 }
1049 }
1050
1051 #[test]
1054 fn linear_model_drift_detection_works_end_to_end() {
1055 let mut detector = detector_with(DriftDetectionMethod::ModelBased(ModelType::Linear));
1056
1057 for round in 0..6 {
1059 detector
1060 .detect_drift(&batch(10.0, 60, round * 60))
1061 .expect("stable rounds must not error");
1062 }
1063
1064 let diagnostics = detector.get_diagnostics();
1065 assert!(
1066 diagnostics.reference_window_size > 0,
1067 "the reference window must retain real observations"
1068 );
1069 }
1070
1071 #[test]
1076 fn stationary_stream_does_not_raise_drift() {
1077 let mut detector = detector_with(DriftDetectionMethod::Statistical(
1078 StatisticalMethod::KolmogorovSmirnov,
1079 ));
1080
1081 let mut fired = 0usize;
1082 for round in 0..12 {
1083 if detector
1084 .detect_drift(&batch(10.0, 60, round * 60))
1085 .expect("detect_drift")
1086 {
1087 fired += 1;
1088 }
1089 }
1090 assert_eq!(
1091 fired, 0,
1092 "a stationary stream raised {fired} drift events out of 12 rounds"
1093 );
1094 assert_eq!(detector.get_drift_state(), &DriftState::Stable);
1095 }
1096
1097 #[test]
1101 fn recorded_drift_events_carry_real_p_values() {
1102 let mut detector = detector_with(DriftDetectionMethod::Statistical(
1103 StatisticalMethod::KolmogorovSmirnov,
1104 ));
1105 detector.detect_drift(&batch(10.0, 120, 0)).expect("warmup");
1106 let fired = detector
1107 .detect_drift(&batch(100.0, 120, 500))
1108 .expect("detect_drift");
1109 assert!(fired, "a 90-unit mean shift must be detected");
1110
1111 let events = detector.get_recent_drift_events(1);
1112 let event = events.first().expect("an event must be recorded");
1113 let p_value = event.p_value.expect("a p-value must be recorded");
1114 for fabricated in [0.01_f64, 0.015, 0.02, 0.5, 0.6, 0.7] {
1115 assert!(
1116 (p_value - fabricated).abs() > 1e-12,
1117 "D1 regression: p-value {p_value} matches the hard-coded literal \
1118 {fabricated}"
1119 );
1120 }
1121 assert!(
1122 (0.0..=1.0).contains(&p_value),
1123 "a p-value must lie in [0, 1], got {p_value}"
1124 );
1125 assert!(
1127 event.magnitude > 0.0,
1128 "the recorded magnitude must be the real test statistic"
1129 );
1130 }
1131
1132 #[test]
1137 fn ensemble_of_distinct_detectors_agrees_on_an_unmistakable_shift() {
1138 let mut detector = detector_with(DriftDetectionMethod::Ensemble {
1139 methods: vec![
1140 DriftDetectionMethod::Statistical(StatisticalMethod::KolmogorovSmirnov),
1141 DriftDetectionMethod::Statistical(StatisticalMethod::MannWhitneyU),
1142 DriftDetectionMethod::Distribution(DistributionMethod::JSDivergence),
1143 ],
1144 voting_strategy: VotingStrategy::Majority,
1145 });
1146
1147 detector.detect_drift(&batch(10.0, 120, 0)).expect("warmup");
1148 let fired = detector
1149 .detect_drift(&batch(500.0, 120, 900))
1150 .expect("detect_drift");
1151 assert!(
1152 fired,
1153 "a 490-unit mean shift must be detected by a majority of three real \
1154 detectors"
1155 );
1156 }
1157}