Skip to main content

optirs_core/streaming/adaptive_streaming/
drift_detection.rs

1// Drift detection and adaptation for streaming data
2//
3// This module provides comprehensive drift detection capabilities including
4// statistical methods, distribution-based approaches, model-based detection,
5// and ensemble methods for identifying concept drift in streaming data.
6
7use 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
22/// Enhanced drift detector with multiple detection methods
23pub struct EnhancedDriftDetector<A: Float + Send + Sync> {
24    /// Configuration for drift detection
25    config: DriftConfig,
26    /// Current detection method
27    detection_method: DriftDetectionMethod,
28    /// Statistical test implementations
29    statistical_tests: HashMap<StatisticalMethod, Box<dyn StatisticalTest<A>>>,
30    /// Distribution comparison methods
31    distribution_methods: HashMap<DistributionMethod, Box<dyn DistributionComparator<A>>>,
32    /// Model-based detectors
33    model_detectors: HashMap<ModelType, Box<dyn ModelBasedDetector<A>>>,
34    /// Ensemble voting strategy
35    /// Detection history
36    detection_history: VecDeque<DriftEvent<A>>,
37    /// False positive tracker
38    false_positive_tracker: FalsePositiveTracker<A>,
39    /// Reference window for comparison
40    reference_window: VecDeque<StreamingDataPoint<A>>,
41    /// Current drift state
42    drift_state: DriftState,
43    /// Last detection timestamp
44    last_detection: Option<Instant>,
45    /// Sensitivity adjustment factor
46    sensitivity_factor: A,
47}
48
49/// Drift event information
50#[derive(Debug, Clone)]
51pub struct DriftEvent<A: Float + Send + Sync> {
52    /// Event timestamp
53    pub timestamp: Instant,
54    /// Drift severity level
55    pub severity: DriftSeverity,
56    /// Detection confidence
57    pub confidence: A,
58    /// Detection method that triggered
59    pub detection_method: String,
60    /// Statistical significance
61    pub p_value: Option<A>,
62    /// Drift magnitude estimate
63    pub magnitude: A,
64    /// Affected features (if applicable)
65    pub affected_features: Vec<usize>,
66}
67
68/// Drift severity levels
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
70pub enum DriftSeverity {
71    /// Minor drift that may not require immediate action
72    Minor,
73    /// Moderate drift requiring attention
74    Moderate,
75    /// Major drift requiring significant adaptation
76    Major,
77    /// Critical drift requiring immediate response
78    Critical,
79}
80
81/// Current drift detection state
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum DriftState {
84    /// Normal operation, no drift detected
85    Stable,
86    /// Warning level - potential drift detected
87    Warning,
88    /// Drift confirmed
89    Drift,
90    /// Recovering from drift
91    Recovery,
92}
93
94/// False positive tracking for drift detection
95pub struct FalsePositiveTracker<A: Float + Send + Sync> {
96    /// Recent false positive events
97    false_positives: VecDeque<Instant>,
98    /// True positive events
99    true_positives: VecDeque<Instant>,
100    /// Current false positive rate
101    current_fp_rate: A,
102    /// Target false positive rate
103    target_fp_rate: A,
104}
105
106/// Trait for statistical drift detection tests
107pub trait StatisticalTest<A: Float + Send + Sync>: Send + Sync {
108    /// Performs the statistical test for drift
109    fn test_for_drift(
110        &mut self,
111        reference: &[A],
112        current: &[A],
113    ) -> Result<DriftTestResult<A>, String>;
114
115    /// Updates test parameters based on historical performance
116    fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String>;
117
118    /// Resets the test state
119    fn reset(&mut self);
120}
121
122/// Result of a drift detection test
123#[derive(Debug, Clone)]
124pub struct DriftTestResult<A: Float + Send + Sync> {
125    /// Whether drift was detected
126    pub drift_detected: bool,
127    /// Statistical significance (p-value)
128    pub p_value: A,
129    /// Test statistic value
130    pub test_statistic: A,
131    /// Confidence in the result
132    pub confidence: A,
133    /// Additional test-specific metadata
134    pub metadata: HashMap<String, A>,
135}
136
137/// Trait for distribution-based drift detection
138pub trait DistributionComparator<A: Float + Send + Sync>: Send + Sync {
139    /// Compares two distributions for drift
140    fn compare_distributions(
141        &self,
142        reference: &[A],
143        current: &[A],
144    ) -> Result<DistributionComparison<A>, String>;
145
146    /// Gets the threshold for drift detection
147    fn get_threshold(&self) -> A;
148
149    /// Updates threshold based on performance
150    fn update_threshold(&mut self, new_threshold: A);
151}
152
153/// Result of distribution comparison
154#[derive(Debug, Clone)]
155pub struct DistributionComparison<A: Float + Send + Sync> {
156    /// Distance/divergence measure
157    pub distance: A,
158    /// Threshold for drift detection
159    pub threshold: A,
160    /// Whether drift was detected
161    pub drift_detected: bool,
162    /// Comparison confidence
163    pub confidence: A,
164}
165
166/// Trait for model-based drift detection
167pub trait ModelBasedDetector<A: Float + Send + Sync>: Send + Sync {
168    /// Updates the model with new data
169    fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String>;
170
171    /// Detects drift based on model performance
172    fn detect_drift(
173        &mut self,
174        data: &[StreamingDataPoint<A>],
175    ) -> Result<ModelDriftResult<A>, String>;
176
177    /// Resets the model
178    fn reset_model(&mut self) -> Result<(), String>;
179}
180
181/// Result of model-based drift detection
182#[derive(Debug, Clone)]
183pub struct ModelDriftResult<A: Float + Send + Sync> {
184    /// Whether drift was detected
185    pub drift_detected: bool,
186    /// Model performance degradation
187    pub performance_degradation: A,
188    /// Drift confidence
189    pub confidence: A,
190    /// Feature importance changes
191    pub feature_importance_changes: Vec<A>,
192}
193
194impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum + 'static> EnhancedDriftDetector<A> {
195    /// Creates a new enhanced drift detector
196    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        // Initialize statistical tests. Every `StatisticalMethod` variant is
212        // backed by a real implementation of the method it names (see
213        // `drift_tests`), so an unregistered method is a genuine configuration
214        // error rather than something to silently substitute a different
215        // statistic for.
216        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        // Initialize distribution methods.
246        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        // In one dimension the Earth Mover's Distance and the first
268        // Wasserstein distance are the same quantity, so both variants map to
269        // the same real optimal-transport computation.
270        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        // Initialize model detectors. Every `ModelType` variant is backed by a
280        // real model of the family it names (see `drift_models`): a linear
281        // regressor, a one-hidden-layer online MLP, a depth-limited CART fit
282        // over a sliding window, and a majority-voting ensemble of the three.
283        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    /// Detects drift in the given batch of data
318    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        // Update reference window
324        self.update_reference_window(batch)?;
325
326        // Check if we have enough data for comparison
327        if self.reference_window.len() < self.config.window_size / 2 {
328            return Ok(false);
329        }
330
331        // Extract features for comparison
332        let current_features = self.extract_features(batch)?;
333        let reference_features = self.extract_reference_features()?;
334
335        // Perform drift detection based on configured method
336        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, &current_features)?
340            }
341            DriftDetectionMethod::Distribution(method) => {
342                self.detect_distribution_drift(&method, &reference_features, &current_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                &current_features,
355                batch,
356            )?,
357        };
358
359        // Update drift state and history
360        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    /// Updates the reference window with new data
370    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    /// Extracts features from a batch of data points
381    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    /// Extracts reference features from the reference window
392    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    /// Performs statistical drift detection
408    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            // The detector's own published decision rule stays authoritative
418            // (ADWIN's Hoeffding cut, DDM's 3-sigma rule, CUSUM's decision
419            // interval, ...) because a raw p-value threshold cannot express
420            // any of them. The adaptive sensitivity factor supplies a second,
421            // independent gate on the *real* p-value: raising the factor makes
422            // the detector fire on evidence that its native rule alone would
423            // have let through. Previously this branch discarded the
424            // detector's verdict entirely and rethresholded a fabricated
425            // p-value.
426            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    /// Performs distribution-based drift detection
447    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            // Every comparator reports `confidence = 1 - p`, where `p` comes
457            // from a real significance test evaluated on the same binning or
458            // the same empirical CDFs as the distance (a G-test for the
459            // histogram divergences, a two-sample KS test for Wasserstein), so
460            // recovering the p-value here is exact rather than a rescaling of
461            // an invented confidence.
462            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    /// Performs model-based drift detection
496    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            // `confidence` is `1 - p` from the detector's own one-sided test on
505            // its real prediction error, so this recovers the true p-value.
506            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    /// Performs ensemble drift detection
545    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        // Collect results from all methods
556        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                    // Avoid recursive ensemble calls
569                    continue;
570                }
571            };
572            results.push(result);
573        }
574
575        // Apply voting strategy
576        let ensemble_result = self.apply_voting_strategy(voting_strategy, &results)?;
577        Ok(ensemble_result)
578    }
579
580    /// Applies the ensemble voting strategy
581    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        // Aggregate confidence and p-values
617        // `results` is non-empty (checked above), so this divisor is never zero.
618        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    /// Handles drift detection event
639    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(), // Could be computed based on feature-wise analysis
650        };
651
652        // Store in history
653        if self.detection_history.len() >= 1000 {
654            self.detection_history.pop_front();
655        }
656        self.detection_history.push_back(drift_event);
657
658        // Update drift state
659        self.update_drift_state(true);
660        self.last_detection = Some(Instant::now());
661
662        // Update false positive tracker if enabled
663        if self.config.enable_false_positive_tracking {
664            self.false_positive_tracker.record_detection(true)?;
665        }
666
667        Ok(())
668    }
669
670    /// Removes a model-based detector, so the "no detector registered" arm of
671    /// [`Self::detect_model_drift`] can be exercised now that every
672    /// `ModelType` variant ships with a real implementation.
673    #[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    /// Classifies drift severity based on test results
682    /// Test-only view of [`Self::classify_drift_severity`].
683    #[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        // Significance-based band (what this used to return on its own).
696        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        // Magnitude-based band from the configured thresholds (CF1).
707        // `DriftConfig::warning_threshold` had no reader at all, so configuring
708        // a stricter warning level changed nothing; `drift_threshold` was only
709        // used by `config.validate()`. Both now bound the reported severity,
710        // and `validate()` guarantees warning < drift.
711        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        // Report the more serious of the two readings: a hugely displaced
721        // statistic matters even when the p-value is unremarkable (small
722        // windows), and a decisive p-value matters even at modest magnitude.
723        by_significance.max(by_magnitude)
724    }
725
726    /// Updates the current drift state
727    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    /// Computes adaptation for drift sensitivity
738    pub fn compute_sensitivity_adaptation(&mut self) -> Result<Option<Adaptation<A>>, String> {
739        // Check if sensitivity should be adjusted based on false positive rate
740        if self.config.enable_false_positive_tracking {
741            let current_fp_rate = self.false_positive_tracker.current_fp_rate;
742            // Read the tracker's own target rather than re-hardcoding 0.05 here:
743            // `FalsePositiveTracker::target_fp_rate` previously had no reader, so
744            // the two could silently disagree.
745            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                    // Too many false positives, decrease sensitivity
752                    -step
753                } else {
754                    // Too few detections (potentially missing true positives), increase sensitivity
755                    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    /// Applies sensitivity adaptation
775    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    /// Checks if drift is currently detected
788    pub fn is_drift_detected(&self) -> bool {
789        matches!(self.drift_state, DriftState::Drift | DriftState::Warning)
790    }
791
792    /// Gets the current drift state
793    pub fn get_drift_state(&self) -> &DriftState {
794        &self.drift_state
795    }
796
797    /// Gets recent drift events
798    pub fn get_recent_drift_events(&self, count: usize) -> Vec<&DriftEvent<A>> {
799        self.detection_history.iter().rev().take(count).collect()
800    }
801
802    /// Resets the drift detector
803    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        // Reset all detection methods
811        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    /// Gets diagnostic information
823    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        // Keep only recent events (last hour).
859        //
860        // `now - Duration` panics when the process has been up for less than
861        // the retention window, so the window is applied as a forward
862        // `duration_since` comparison rather than a materialised cutoff.
863        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        // Update false positive rate
870        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/// Diagnostic information for drift detection
881#[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    /// D1: every `StatisticalMethod` variant is now backed by a real
922    /// implementation of the method it names, so constructing a detector for any
923    /// of them succeeds — and none of them silently substitutes a different
924    /// statistic.
925    #[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            // Warm-up, then a genuine shift.
938            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    /// D1: every `DistributionMethod` variant is registered with a real
948    /// divergence, so the dishonest "compute a Jensen-Shannon divergence and
949    /// report it as whatever the caller asked for" fallback is gone.
950    #[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    /// D1/F1: every `ModelType` variant is now backed by a real model of the
970    /// family it names (`drift_models`), so constructing a detector for any of
971    /// them and running it end-to-end succeeds. This test used to assert the
972    /// opposite — that `NeuralNetwork`, `DecisionTree` and `Ensemble` returned
973    /// an honest "not registered" error — which was the correct behaviour while
974    /// those three were name-only variants.
975    ///
976    /// The error arm itself is still live and still correct: it fires for a
977    /// `ModelType` that is genuinely absent from the map, which
978    /// `model_type_without_a_registered_detector_is_an_honest_error` covers.
979    #[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    /// D1: a `ModelType` with no registered detector must be an honest error
1000    /// rather than quietly computing a *different* quantity and reporting it
1001    /// under the requested model's name.
1002    #[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    /// F1: the three newly implemented model families reach a drift verdict
1018    /// through the public `EnhancedDriftDetector` path on an unmistakable shift
1019    /// in the target relationship.
1020    #[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    /// D2: `ModelType::Linear` is backed by a real online regressor, so it works
1052    /// end-to-end on labelled data and reports a real degradation figure.
1053    #[test]
1054    fn linear_model_drift_detection_works_end_to_end() {
1055        let mut detector = detector_with(DriftDetectionMethod::ModelBased(ModelType::Linear));
1056
1057        // Learn a stable relationship.
1058        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    /// D1: with a real detector, a stationary stream must not raise a drift
1072    /// event. Against the pre-fix ADWIN — which compared a raw mean difference
1073    /// against `sensitivity = 0.05` used as an absolute magnitude — ordinary
1074    /// noise on a stream at level 10 would clear that threshold constantly.
1075    #[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    /// D1: p-values recorded on real drift events must be genuine values, not one
1098    /// of the handful of hard-coded literals (`0.01`, `0.02`, `0.015`, `0.5`,
1099    /// `0.6`, `0.7`) the old detectors returned.
1100    #[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        // The recorded metadata must carry the detector's own diagnostics.
1126        assert!(
1127            event.magnitude > 0.0,
1128            "the recorded magnitude must be the real test statistic"
1129        );
1130    }
1131
1132    /// D1: the ensemble path must aggregate genuinely different detectors. The
1133    /// old code made every member compute the same mean difference, so a
1134    /// unanimous vote was free; with real, distinct detectors a unanimous vote is
1135    /// meaningful and must still fire on an unmistakable shift.
1136    #[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}