Skip to main content

optirs_core/streaming/
concept_drift.rs

1// Concept drift detection and adaptation for streaming optimization
2//
3// This module provides various algorithms for detecting when the underlying
4// data distribution changes (concept drift) and adapting the optimizer accordingly.
5
6use scirs2_core::numeric::Float;
7use std::collections::VecDeque;
8use std::iter::Sum;
9use std::time::{Duration, Instant};
10
11use crate::error::Result;
12use crate::utils::scalar_or;
13
14#[cfg(test)]
15mod drift_regression_tests;
16
17/// Types of concept drift detection algorithms
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum DriftDetectionMethod {
20    /// Page-Hinkley test for change detection
21    PageHinkley,
22    /// ADWIN (Adaptive Windowing) algorithm
23    Adwin,
24    /// Drift Detection Method (DDM)
25    DriftDetectionMethod,
26    /// Early Drift Detection Method (EDDM)
27    EarlyDriftDetection,
28    /// Statistical test-based detection
29    StatisticalTest,
30    /// Ensemble-based detection
31    Ensemble,
32}
33
34/// Concept drift detector configuration
35#[derive(Debug, Clone)]
36pub struct DriftDetectorConfig {
37    /// Detection method to use
38    pub method: DriftDetectionMethod,
39    /// Minimum samples before detection
40    pub min_samples: usize,
41    /// Detection threshold
42    pub threshold: f64,
43    /// Window size for statistical methods
44    pub window_size: usize,
45    /// Alpha value for statistical tests
46    pub alpha: f64,
47    /// Warning threshold (before drift)
48    pub warningthreshold: f64,
49    /// Enable ensemble detection
50    pub enable_ensemble: bool,
51}
52
53impl Default for DriftDetectorConfig {
54    fn default() -> Self {
55        Self {
56            method: DriftDetectionMethod::PageHinkley,
57            min_samples: 30,
58            threshold: 3.0,
59            window_size: 100,
60            alpha: 0.005,
61            warningthreshold: 2.0,
62            enable_ensemble: false,
63        }
64    }
65}
66
67/// Concept drift detection result
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub enum DriftStatus {
70    /// No drift detected
71    Stable,
72    /// Warning level - potential drift
73    Warning,
74    /// Drift detected
75    Drift,
76}
77
78/// Drift detection event
79#[derive(Debug, Clone)]
80pub struct DriftEvent<A: Float + Send + Sync> {
81    /// Timestamp of detection
82    pub timestamp: Instant,
83    /// Detection confidence (0.0 to 1.0)
84    pub confidence: A,
85    /// Type of drift detected
86    pub drift_type: DriftType,
87    /// Recommendation for adaptation
88    pub adaptation_recommendation: AdaptationRecommendation,
89}
90
91/// Types of concept drift
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub enum DriftType {
94    /// Sudden/abrupt drift
95    Sudden,
96    /// Gradual drift
97    Gradual,
98    /// Incremental drift
99    Incremental,
100    /// Recurring drift
101    Recurring,
102    /// Blip (temporary change)
103    Blip,
104}
105
106/// Recommendations for adapting to drift
107#[derive(Debug, Clone)]
108pub enum AdaptationRecommendation {
109    /// Reset optimizer state
110    Reset,
111    /// Increase learning rate
112    IncreaseLearningRate { factor: f64 },
113    /// Decrease learning rate
114    DecreaseLearningRate { factor: f64 },
115    /// Use different optimizer
116    SwitchOptimizer { new_optimizer: String },
117    /// Adjust window size
118    AdjustWindow { new_size: usize },
119    /// No adaptation needed
120    NoAction,
121}
122
123/// Page-Hinkley drift detector
124#[derive(Debug, Clone)]
125pub struct PageHinkleyDetector<A: Float + Send + Sync> {
126    /// Cumulative sum
127    sum: A,
128    /// Minimum cumulative sum seen
129    min_sum: A,
130    /// Detection threshold
131    threshold: A,
132    /// Warning threshold
133    warningthreshold: A,
134    /// Sample count
135    sample_count: usize,
136    /// Last drift time
137    last_drift: Option<Instant>,
138    /// Running mean of observed losses under the null hypothesis of no
139    /// drift (C1 fix): the classic Page-Hinkley test (Gama et al., 2004)
140    /// computes `x̄_t`, the incremental mean of *all* samples seen so far
141    /// (including the current one), and accumulates `sum(x_t - x̄_t)`. The
142    /// previous code used a hardcoded `0.1` in place of `x̄_t`, so the
143    /// detector only behaved correctly for streams whose stable loss
144    /// happened to sit near 0.1 — any other baseline made `sum` drift
145    /// monotonically regardless of real drift, eventually firing false
146    /// positives (or, for a baseline well below 0.1, never firing at all).
147    running_mean: A,
148}
149
150impl<A: Float + Send + Sync + Send + Sync> PageHinkleyDetector<A> {
151    /// Create a new Page-Hinkley detector
152    pub fn new(threshold: A, warningthreshold: A) -> Self {
153        Self {
154            sum: A::zero(),
155            min_sum: A::zero(),
156            threshold,
157            warningthreshold,
158            sample_count: 0,
159            last_drift: None,
160            running_mean: A::zero(),
161        }
162    }
163
164    /// Update detector with new loss value
165    pub fn update(&mut self, loss: A) -> DriftStatus {
166        self.sample_count += 1;
167
168        // Incremental mean update (Welford-style): `running_mean` becomes
169        // the mean of all `sample_count` losses seen so far, including this
170        // one, matching the standard Page-Hinkley `x̄_t` (C1 fix).
171        let count = A::from(self.sample_count).unwrap_or(A::one());
172        self.running_mean = self.running_mean + (loss - self.running_mean) / count;
173
174        // Update cumulative sum (assuming we want to detect increases in loss)
175        self.sum = self.sum + loss - self.running_mean;
176
177        // Update minimum
178        if self.sum < self.min_sum {
179            self.min_sum = self.sum;
180        }
181
182        // Compute test statistic
183        let test_stat = self.sum - self.min_sum;
184
185        if test_stat > self.threshold {
186            self.last_drift = Some(Instant::now());
187            self.reset();
188            DriftStatus::Drift
189        } else if test_stat > self.warningthreshold {
190            DriftStatus::Warning
191        } else {
192            DriftStatus::Stable
193        }
194    }
195
196    /// Replace the decision thresholds *without* discarding the accumulated
197    /// statistic (C6). Rebuilding the detector to change a threshold would
198    /// reset `sum`/`min_sum` on every adaptation, so the cumulative test could
199    /// never reach any threshold at all.
200    pub fn set_thresholds(&mut self, threshold: A, warningthreshold: A) {
201        self.threshold = threshold;
202        self.warningthreshold = warningthreshold;
203    }
204
205    /// Current detection threshold.
206    pub fn threshold(&self) -> A {
207        self.threshold
208    }
209
210    /// Current warning threshold.
211    pub fn warning_threshold(&self) -> A {
212        self.warningthreshold
213    }
214
215    /// Reset detector state
216    pub fn reset(&mut self) {
217        self.sum = A::zero();
218        self.min_sum = A::zero();
219        self.sample_count = 0;
220        self.running_mean = A::zero();
221    }
222}
223
224/// ADWIN (Adaptive Windowing) drift detector
225#[derive(Debug, Clone)]
226pub struct AdwinDetector<A: Float + Send + Sync> {
227    /// Window of recent values
228    window: VecDeque<A>,
229    /// Maximum window size
230    max_windowsize: usize,
231    /// Detection confidence level
232    delta: A,
233    /// Minimum window size for detection
234    min_window_size: usize,
235}
236
237impl<A: Float + Sum + Send + Sync + Send + Sync> AdwinDetector<A> {
238    /// Create a new ADWIN detector
239    pub fn new(delta: A, max_windowsize: usize) -> Self {
240        Self {
241            window: VecDeque::new(),
242            max_windowsize,
243            delta,
244            min_window_size: 10,
245        }
246    }
247
248    /// Replace the confidence parameter without discarding the window (C6).
249    pub fn set_delta(&mut self, delta: A) {
250        self.delta = delta;
251    }
252
253    /// Current confidence parameter.
254    pub fn delta(&self) -> A {
255        self.delta
256    }
257
258    /// Update detector with new value
259    pub fn update(&mut self, value: A) -> DriftStatus {
260        self.window.push_back(value);
261
262        // Maintain window size
263        if self.window.len() > self.max_windowsize {
264            self.window.pop_front();
265        }
266
267        // Check for drift
268        if self.window.len() >= self.min_window_size {
269            if self.detect_change() {
270                self.shrink_window();
271                DriftStatus::Drift
272            } else {
273                DriftStatus::Stable
274            }
275        } else {
276            DriftStatus::Stable
277        }
278    }
279
280    /// Detect change using the ADWIN algorithm (Bifet & Gavaldà, 2007).
281    ///
282    /// C2 fix: the previous implementation checked only the single midpoint
283    /// split and used an ad hoc `sqrt(var1 + var2 + 0.01)` threshold that
284    /// never read `delta` at all, so the detector's configured confidence
285    /// level had zero effect on its behavior. This checks every valid split
286    /// point `n0 = 1..n` (as real ADWIN does — a true change can occur
287    /// anywhere in the window, not just at the middle) using the standard
288    /// Hoeffding-bound cut condition: for sub-windows of size `n0`, `n1`
289    /// with means `mean0`, `mean1`, a cut is declared where
290    /// `|mean0 - mean1| > eps_cut`, with
291    /// `eps_cut = sqrt((1 / (2*m)) * ln(4 / delta))`,
292    /// `m = 1 / (1/n0 + 1/n1)` (the harmonic-mean-style combined size ADWIN
293    /// uses), which directly incorporates the detector's `delta` confidence
294    /// parameter: a smaller `delta` (higher confidence) requires a larger
295    /// mean gap before declaring a change.
296    fn detect_change(&self) -> bool {
297        let n = self.window.len();
298        if n < 2 {
299            return false;
300        }
301
302        let values: Vec<A> = self.window.iter().cloned().collect();
303        // Prefix sums so every split's sub-window mean is O(1) to compute.
304        let mut prefix = Vec::with_capacity(n + 1);
305        prefix.push(A::zero());
306        for &v in &values {
307            prefix.push(*prefix.last().unwrap_or(&A::zero()) + v);
308        }
309        let total = prefix[n];
310
311        // The textbook Hoeffding bound assumes values in [0, 1]; real
312        // losses/metrics are not naturally bounded that way. Following the
313        // common practical adaptation (as in e.g. river's/scikit-multiflow's
314        // ADWIN), scale by the window's observed range `R = max - min` as an
315        // empirical stand-in for the a-priori bound, so the same relative
316        // sensitivity holds regardless of the metric's absolute scale.
317        let min_v = values
318            .iter()
319            .cloned()
320            .fold(values[0], |a, b| if b < a { b } else { a });
321        let max_v = values
322            .iter()
323            .cloned()
324            .fold(values[0], |a, b| if b > a { b } else { a });
325        let range = (max_v - min_v).max(A::from(1e-12).unwrap_or(A::zero()));
326
327        let four = A::from(4.0).unwrap_or(A::one());
328        let two = A::from(2.0).unwrap_or(A::one());
329        let ln_term = (four / self.delta.max(A::from(1e-12).unwrap_or(A::zero()))).ln();
330
331        for (offset, &sum0) in prefix[1..n].iter().enumerate() {
332            let n0 = offset + 1;
333            let n1 = n - n0;
334            let n0_a = A::from(n0).unwrap_or(A::one());
335            let n1_a = A::from(n1).unwrap_or(A::one());
336
337            let sum1 = total - sum0;
338            let mean0 = sum0 / n0_a;
339            let mean1 = sum1 / n1_a;
340
341            // Harmonic-mean-style combined size `m = 1 / (1/n0 + 1/n1)`.
342            let m = A::one() / (A::one() / n0_a + A::one() / n1_a);
343            let eps_cut = range * (ln_term / (two * m)).sqrt();
344
345            if (mean0 - mean1).abs() > eps_cut {
346                return true;
347            }
348        }
349
350        false
351    }
352
353    /// Shrink window after drift detection
354    fn shrink_window(&mut self) {
355        let new_size = self.window.len() / 2;
356        while self.window.len() > new_size {
357            self.window.pop_front();
358        }
359    }
360}
361
362/// DDM (Drift Detection Method) detector.
363///
364/// C3: implemented per Gama et al., "Learning with Drift Detection" (2004).
365/// The detector tracks the online error rate `p_i` and its standard deviation
366/// `s_i = sqrt(p_i (1 - p_i) / i)`, remembers the pair `(p_min, s_min)` observed
367/// at the *minimum of `p_i + s_i`*, and compares the current `p_i + s_i`
368/// against `p_min + 2*s_min` (warning) and `p_min + 3*s_min` (drift).
369///
370/// The previous implementation instead tracked `min(p_i + 2*s_i)` in
371/// `min_error_plus_2_std` and set `min_error_plus_3_std` to `p_i + 3*s_i` *at
372/// that same moment*. The published `2*s_min` / `3*s_min` margins were
373/// therefore never applied: the warning test degenerated to "is the current
374/// level above the smallest level ever seen", which fires on essentially any
375/// upward noise, and the drift test compared `p + 2s` against `p_min + 3*s_min`
376/// where both terms came from different definitions. It also seeded
377/// `error_std = 1.0`, which is not a possible standard deviation for a rate in
378/// `[0, 1]`.
379#[derive(Debug, Clone)]
380pub struct DdmDetector<A: Float + Send + Sync> {
381    /// Current error rate `p_i`
382    error_rate: A,
383    /// Current standard deviation `s_i`
384    error_std: A,
385    /// Error rate at the minimum of `p_i + s_i`
386    p_min: Option<A>,
387    /// Standard deviation at the minimum of `p_i + s_i`
388    s_min: Option<A>,
389    /// Sample count
390    sample_count: usize,
391    /// Error count
392    error_count: usize,
393    /// Samples required before the detector starts testing
394    warmup: usize,
395}
396
397impl<A: Float + Send + Sync + Send + Sync> DdmDetector<A> {
398    /// Minimum samples before the DDM statistics are meaningful (the value
399    /// used in the original paper).
400    pub const DEFAULT_WARMUP: usize = 30;
401
402    /// Create a new DDM detector
403    pub fn new() -> Self {
404        Self::with_warmup(Self::DEFAULT_WARMUP)
405    }
406
407    /// Create a DDM detector with a custom warm-up length.
408    pub fn with_warmup(warmup: usize) -> Self {
409        Self {
410            error_rate: A::zero(),
411            error_std: A::zero(),
412            p_min: None,
413            s_min: None,
414            sample_count: 0,
415            error_count: 0,
416            warmup: warmup.max(2),
417        }
418    }
419
420    /// Current error rate estimate.
421    pub fn error_rate(&self) -> A {
422        self.error_rate
423    }
424
425    /// Current warning level `p_min + 2*s_min`, if the baseline is established.
426    pub fn warning_level(&self) -> Option<A> {
427        let (p_min, s_min) = (self.p_min?, self.s_min?);
428        Some(p_min + A::from(2.0)? * s_min)
429    }
430
431    /// Current drift level `p_min + 3*s_min`, if the baseline is established.
432    pub fn drift_level(&self) -> Option<A> {
433        let (p_min, s_min) = (self.p_min?, self.s_min?);
434        Some(p_min + A::from(3.0)? * s_min)
435    }
436
437    /// Update with prediction result
438    pub fn update(&mut self, iserror: bool) -> DriftStatus {
439        self.sample_count += 1;
440        if iserror {
441            self.error_count += 1;
442        }
443
444        let n = match A::from(self.sample_count as f64) {
445            Some(n) if n > A::zero() => n,
446            _ => return DriftStatus::Stable,
447        };
448        let p = A::from(self.error_count as f64).unwrap_or_else(A::zero) / n;
449        // `p (1 - p) / n` is non-negative for any p in [0, 1]; clamp defensively
450        // so a rounding artefact can never feed a NaN into `sqrt`.
451        let variance = (p * (A::one() - p) / n).max(A::zero());
452        self.error_rate = p;
453        self.error_std = variance.sqrt();
454
455        if self.sample_count < self.warmup {
456            // The baseline is only meaningful once the rate has settled; seeding
457            // it from the first few samples is what made the original detector
458            // fire immediately.
459            return DriftStatus::Stable;
460        }
461
462        let level = p + self.error_std;
463        match (self.p_min, self.s_min) {
464            (Some(p_min), Some(s_min)) if level >= p_min + s_min => {}
465            _ => {
466                self.p_min = Some(p);
467                self.s_min = Some(self.error_std);
468            }
469        }
470
471        let Some(warning_level) = self.warning_level() else {
472            return DriftStatus::Stable;
473        };
474        let Some(drift_level) = self.drift_level() else {
475            return DriftStatus::Stable;
476        };
477
478        // Strict comparisons: for a stream that has seen no errors at all,
479        // `p_min` and `s_min` are both exactly 0, and a non-strict test would
480        // report drift on the first post-warm-up sample of a perfectly clean
481        // stream.
482        if level > drift_level {
483            self.reset();
484            DriftStatus::Drift
485        } else if level > warning_level {
486            DriftStatus::Warning
487        } else {
488            DriftStatus::Stable
489        }
490    }
491
492    /// Reset detector state
493    pub fn reset(&mut self) {
494        self.sample_count = 0;
495        self.error_count = 0;
496        self.error_rate = A::zero();
497        self.error_std = A::zero();
498        self.p_min = None;
499        self.s_min = None;
500    }
501}
502
503impl<A: Float + Send + Sync + Send + Sync> Default for DdmDetector<A> {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509/// Comprehensive concept drift detector
510pub struct ConceptDriftDetector<A: Float + Send + Sync> {
511    /// Configuration
512    config: DriftDetectorConfig,
513
514    /// Page-Hinkley detector
515    ph_detector: PageHinkleyDetector<A>,
516
517    /// ADWIN detector
518    adwin_detector: AdwinDetector<A>,
519
520    /// DDM detector
521    ddm_detector: DdmDetector<A>,
522
523    /// Ensemble voting history
524    ensemble_history: VecDeque<DriftStatus>,
525
526    /// Drift events history
527    drift_events: Vec<DriftEvent<A>>,
528
529    /// Performance before/after drift
530    performance_tracker: PerformanceDriftTracker<A>,
531}
532
533impl<A: Float + std::fmt::Debug + Sum + Send + Sync + Send + Sync> ConceptDriftDetector<A> {
534    /// Bound on the retained ensemble decision history (C7).
535    pub const ENSEMBLE_HISTORY_CAPACITY: usize = 64;
536
537    /// Bound on the retained drift-event log (C7): an unbounded `Vec` here grows
538    /// for the lifetime of a long-running stream.
539    pub const DRIFT_EVENT_CAPACITY: usize = 1024;
540
541    /// Create a new concept drift detector
542    pub fn new(config: DriftDetectorConfig) -> Self {
543        let threshold = scalar_or(config.threshold, A::zero());
544        let warningthreshold = scalar_or(config.warningthreshold, A::zero());
545        let delta = scalar_or(config.alpha, A::zero());
546
547        Self {
548            ph_detector: PageHinkleyDetector::new(threshold, warningthreshold),
549            adwin_detector: AdwinDetector::new(delta, config.window_size),
550            ddm_detector: DdmDetector::new(),
551            ensemble_history: VecDeque::with_capacity(10),
552            drift_events: Vec::new(),
553            performance_tracker: PerformanceDriftTracker::new(),
554            config,
555        }
556    }
557
558    /// Update detector with new loss and prediction error
559    pub fn update(&mut self, loss: A, is_predictionerror: bool) -> Result<DriftStatus> {
560        let ph_status = self.ph_detector.update(loss);
561        let adwin_status = self.adwin_detector.update(loss);
562        let ddm_status = self.ddm_detector.update(is_predictionerror);
563
564        let final_status = if self.config.enable_ensemble {
565            self.ensemble_vote(ph_status, adwin_status, ddm_status)
566        } else {
567            match self.config.method {
568                DriftDetectionMethod::PageHinkley => ph_status,
569                DriftDetectionMethod::Adwin => adwin_status,
570                DriftDetectionMethod::DriftDetectionMethod => ddm_status,
571                _ => ddm_status, // Fallback for EarlyDriftDetection, StatisticalTest, Ensemble
572            }
573        };
574
575        // C7: the ensemble decision history is now actually recorded (it used
576        // to be allocated in the constructor and never written), bounded to
577        // `ENSEMBLE_HISTORY_CAPACITY`, and read back to derive a real
578        // confidence.
579        self.ensemble_history.push_back(final_status);
580        while self.ensemble_history.len() > Self::ENSEMBLE_HISTORY_CAPACITY {
581            self.ensemble_history.pop_front();
582        }
583
584        // Record drift event if detected
585        if final_status == DriftStatus::Drift {
586            let event = DriftEvent {
587                timestamp: Instant::now(),
588                // Real confidence: how strongly the detectors agreed on this
589                // sample, tempered by how persistent the recent signal has been.
590                confidence: self.detection_confidence(ph_status, adwin_status, ddm_status),
591                drift_type: self.classify_drift_type(),
592                adaptation_recommendation: self.generate_adaptation_recommendation(),
593            };
594            self.drift_events.push(event);
595            while self.drift_events.len() > Self::DRIFT_EVENT_CAPACITY {
596                self.drift_events.remove(0);
597            }
598        }
599
600        // Update performance tracking
601        self.performance_tracker.update(loss, final_status);
602
603        Ok(final_status)
604    }
605
606    /// Confidence in a detection, from detector agreement and signal
607    /// persistence. Replaces the hardcoded `0.8` that every drift event used to
608    /// carry regardless of how the detectors actually voted.
609    fn detection_confidence(&self, ph: DriftStatus, adwin: DriftStatus, ddm: DriftStatus) -> A {
610        let votes = [ph, adwin, ddm];
611        let drift_votes = votes.iter().filter(|&&s| s == DriftStatus::Drift).count();
612        let warning_votes = votes.iter().filter(|&&s| s == DriftStatus::Warning).count();
613        let agreement = (drift_votes as f64 + 0.5 * warning_votes as f64) / votes.len() as f64;
614
615        // Persistence: the share of the retained ensemble history that is not
616        // Stable. A single isolated spike is less trustworthy than a sustained
617        // signal.
618        let persistence = if self.ensemble_history.is_empty() {
619            0.0
620        } else {
621            self.ensemble_history
622                .iter()
623                .filter(|status| **status != DriftStatus::Stable)
624                .count() as f64
625                / self.ensemble_history.len() as f64
626        };
627
628        let confidence = (0.7 * agreement + 0.3 * persistence).clamp(0.0, 1.0);
629        A::from(confidence).unwrap_or_else(A::zero)
630    }
631
632    /// Recent ensemble decisions, oldest first.
633    pub fn ensemble_history(&self) -> &VecDeque<DriftStatus> {
634        &self.ensemble_history
635    }
636
637    /// Ensemble voting among detectors
638    fn ensemble_vote(
639        &mut self,
640        ph: DriftStatus,
641        adwin: DriftStatus,
642        ddm: DriftStatus,
643    ) -> DriftStatus {
644        let votes = [ph, adwin, ddm];
645
646        // Count votes
647        let drift_votes = votes.iter().filter(|&&s| s == DriftStatus::Drift).count();
648        let warning_votes = votes.iter().filter(|&&s| s == DriftStatus::Warning).count();
649
650        if drift_votes >= 2 {
651            DriftStatus::Drift
652        } else if warning_votes >= 2 || drift_votes >= 1 {
653            DriftStatus::Warning
654        } else {
655            DriftStatus::Stable
656        }
657    }
658
659    /// Classify the type of drift based on recent history
660    fn classify_drift_type(&self) -> DriftType {
661        // Simplified classification based on recent drift events
662        if self.drift_events.len() < 2 {
663            return DriftType::Sudden;
664        }
665
666        let recent_events = self.drift_events.iter().rev().take(5);
667        let time_intervals: Vec<_> = recent_events
668            .map(|event| event.timestamp)
669            .collect::<Vec<_>>()
670            .windows(2)
671            .map(|window| window[0].duration_since(window[1]))
672            .collect();
673
674        if time_intervals.iter().all(|&d| d < Duration::from_secs(60)) {
675            DriftType::Sudden
676        } else if time_intervals.len() > 2 {
677            DriftType::Gradual
678        } else {
679            DriftType::Incremental
680        }
681    }
682
683    /// Generate adaptation recommendation based on drift characteristics
684    fn generate_adaptation_recommendation(&self) -> AdaptationRecommendation {
685        let recent_performance = self.performance_tracker.get_recent_performance_change();
686
687        if recent_performance > scalar_or(0.5, A::zero()) {
688            // Significant performance degradation
689            AdaptationRecommendation::Reset
690        } else if recent_performance > scalar_or(0.2, A::zero()) {
691            // Moderate degradation
692            AdaptationRecommendation::IncreaseLearningRate { factor: 1.5 }
693        } else if recent_performance < scalar_or(-0.1, A::zero()) {
694            // Performance improved (suspicious)
695            AdaptationRecommendation::DecreaseLearningRate { factor: 0.8 }
696        } else {
697            AdaptationRecommendation::NoAction
698        }
699    }
700
701    /// Get drift detection statistics
702    pub fn get_statistics(&self) -> DriftStatistics<A> {
703        DriftStatistics {
704            total_drifts: self.drift_events.len(),
705            recent_drift_rate: self.calculate_recent_drift_rate(),
706            average_drift_confidence: self.calculate_average_confidence(),
707            drift_types_distribution: self.calculate_drift_type_distribution(),
708            time_since_last_drift: self.time_since_last_drift(),
709        }
710    }
711
712    fn calculate_recent_drift_rate(&self) -> f64 {
713        // Calculate drift rate in the last hour.
714        //
715        // `Instant::now() - Duration` panics when the process has been up for
716        // less than the window (the resulting instant is not representable), so
717        // the window is applied as a forward `duration_since` comparison
718        // instead of by materialising a cutoff instant.
719        let recent_window = Duration::from_secs(3600);
720        let now = Instant::now();
721        let recent_drifts = self
722            .drift_events
723            .iter()
724            .filter(|event| now.duration_since(event.timestamp) <= recent_window)
725            .count();
726        recent_drifts as f64 / recent_window.as_secs_f64() // Drifts per second
727    }
728
729    fn calculate_average_confidence(&self) -> Option<A> {
730        if self.drift_events.is_empty() {
731            None
732        } else {
733            let sum = self
734                .drift_events
735                .iter()
736                .map(|event| event.confidence)
737                .sum::<A>();
738            Some(sum / scalar_or(self.drift_events.len(), A::one()))
739        }
740    }
741
742    fn calculate_drift_type_distribution(&self) -> std::collections::HashMap<DriftType, usize> {
743        let mut distribution = std::collections::HashMap::new();
744        for event in &self.drift_events {
745            *distribution.entry(event.drift_type).or_insert(0) += 1;
746        }
747        distribution
748    }
749
750    fn time_since_last_drift(&self) -> Option<Duration> {
751        self.drift_events
752            .last()
753            .map(|event| event.timestamp.elapsed())
754    }
755}
756
757/// Performance tracker for drift impact analysis
758#[derive(Debug, Clone)]
759struct PerformanceDriftTracker<A: Float + Send + Sync> {
760    /// Performance history with drift annotations
761    performance_history: VecDeque<(A, DriftStatus, Instant)>,
762    /// Window size for analysis
763    window_size: usize,
764}
765
766impl<A: Float + std::iter::Sum + Send + Sync + Send + Sync> PerformanceDriftTracker<A> {
767    fn new() -> Self {
768        Self {
769            performance_history: VecDeque::new(),
770            window_size: 100,
771        }
772    }
773
774    fn update(&mut self, performance: A, driftstatus: DriftStatus) {
775        self.performance_history
776            .push_back((performance, driftstatus, Instant::now()));
777
778        // Maintain window size
779        if self.performance_history.len() > self.window_size {
780            self.performance_history.pop_front();
781        }
782    }
783
784    /// Get recent performance change (positive = degradation, negative = improvement)
785    fn get_recent_performance_change(&self) -> A {
786        if self.performance_history.len() < 10 {
787            return A::zero();
788        }
789
790        let recent: Vec<_> = self.performance_history.iter().rev().take(10).collect();
791        let older: Vec<_> = self
792            .performance_history
793            .iter()
794            .rev()
795            .skip(10)
796            .take(10)
797            .collect();
798
799        if older.is_empty() {
800            return A::zero();
801        }
802
803        let recent_avg =
804            recent.iter().map(|(p, _, _)| *p).sum::<A>() / scalar_or(recent.len(), A::one());
805        let older_avg =
806            older.iter().map(|(p, _, _)| *p).sum::<A>() / scalar_or(older.len(), A::one());
807
808        recent_avg - older_avg
809    }
810}
811
812/// Drift detection statistics
813#[derive(Debug, Clone)]
814pub struct DriftStatistics<A: Float + Send + Sync> {
815    /// Total number of drifts detected
816    pub total_drifts: usize,
817    /// Recent drift rate (drifts per second)
818    pub recent_drift_rate: f64,
819    /// Average confidence of drift detections
820    pub average_drift_confidence: Option<A>,
821    /// Distribution of drift types
822    pub drift_types_distribution: std::collections::HashMap<DriftType, usize>,
823    /// Time since last drift
824    pub time_since_last_drift: Option<Duration>,
825}
826
827/// Advanced concept drift analysis and adaptation
828pub mod advanced_drift_analysis {
829    use super::*;
830    use std::collections::HashMap;
831
832    /// Advanced drift detector with machine learning-based detection
833    #[derive(Debug)]
834    pub struct AdvancedDriftDetector<A: Float + Send + Sync> {
835        /// Base detector ensemble
836        base_detectors: Vec<Box<dyn DriftDetectorTrait<A>>>,
837
838        /// Drift pattern analyzer
839        pattern_analyzer: DriftPatternAnalyzer<A>,
840
841        /// Adaptive threshold manager
842        threshold_manager: AdaptiveThresholdManager<A>,
843
844        /// Context-aware drift detection
845        context_detector: ContextAwareDriftDetector<A>,
846
847        /// Performance impact analyzer
848        impact_analyzer: DriftImpactAnalyzer<A>,
849
850        /// Adaptation strategy selector
851        adaptation_selector: AdaptationStrategySelector<A>,
852
853        /// Historical drift database
854        drift_database: DriftDatabase<A>,
855    }
856
857    /// Trait for all drift detectors
858    pub trait DriftDetectorTrait<A: Float + Send + Sync>: std::fmt::Debug {
859        fn update(&mut self, value: A) -> DriftStatus;
860        fn reset(&mut self);
861        fn get_confidence(&self) -> A;
862
863        /// Human-readable detector name, used to key adaptive thresholds.
864        fn name(&self) -> &str;
865
866        /// Apply an adapted decision threshold (C6). Without this the adaptive
867        /// threshold manager computed thresholds that nothing ever consumed.
868        fn set_threshold(&mut self, threshold: A);
869
870        /// The threshold currently in force.
871        fn threshold(&self) -> A;
872    }
873
874    /// Drift pattern analyzer for characterizing drift behavior
875    #[derive(Debug)]
876    pub struct DriftPatternAnalyzer<A: Float + Send + Sync> {
877        /// Pattern history buffer
878        pub(crate) pattern_buffer: VecDeque<PatternFeatures<A>>,
879
880        /// Rolling raw values the features are extracted from (C4: the analyzer
881        /// used to be handed a single value per call, so variance was always 0)
882        pub(crate) value_buffer: VecDeque<A>,
883
884        /// Window length for feature extraction
885        pub(crate) window: usize,
886
887        /// Learned drift patterns
888        pub(crate) known_patterns: HashMap<String, DriftPattern<A>>,
889
890        /// Pattern matching threshold
891        pub(crate) matching_threshold: A,
892
893        /// Feature extractors
894        pub(crate) feature_extractors: Vec<Box<dyn FeatureExtractor<A>>>,
895    }
896
897    /// Pattern features for drift characterization.
898    ///
899    /// C4: everything beyond the first two moments needs a window of samples to
900    /// exist at all. Those fields are therefore `Option`: they are `None` until
901    /// the analyzer has enough history, instead of carrying the placeholder
902    /// zeros (and the fabricated `fractal_dimension: 1.5`) they used to. The
903    /// `entropy` field in particular used to be `variance.ln().abs()`, which is
904    /// `+inf` for the zero-variance single-sample window it was always called
905    /// with.
906    #[derive(Debug, Clone)]
907    pub struct PatternFeatures<A: Float + Send + Sync> {
908        /// Statistical moments
909        pub mean: A,
910        pub variance: A,
911        pub skewness: Option<A>,
912        pub kurtosis: Option<A>,
913
914        /// Trend indicators
915        pub trend_slope: Option<A>,
916        pub trend_strength: Option<A>,
917
918        /// Frequency domain features
919        pub dominant_frequency: Option<A>,
920        pub spectral_entropy: Option<A>,
921
922        /// Temporal features
923        pub temporal_locality: Option<A>,
924        pub persistence: Option<A>,
925
926        /// Complexity measures
927        pub entropy: Option<A>,
928        pub fractal_dimension: Option<A>,
929    }
930
931    impl<A: Float + Send + Sync> PatternFeatures<A> {
932        /// The feature vector used for similarity search: `(name, value)` pairs
933        /// for every feature that actually has a value.
934        pub fn named_values(&self) -> Vec<(&'static str, A)> {
935            let mut values: Vec<(&'static str, A)> =
936                vec![("mean", self.mean), ("variance", self.variance)];
937            let optional: [(&'static str, Option<A>); 10] = [
938                ("skewness", self.skewness),
939                ("kurtosis", self.kurtosis),
940                ("trend_slope", self.trend_slope),
941                ("trend_strength", self.trend_strength),
942                ("dominant_frequency", self.dominant_frequency),
943                ("spectral_entropy", self.spectral_entropy),
944                ("temporal_locality", self.temporal_locality),
945                ("persistence", self.persistence),
946                ("entropy", self.entropy),
947                ("fractal_dimension", self.fractal_dimension),
948            ];
949            for (name, value) in optional {
950                if let Some(value) = value {
951                    values.push((name, value));
952                }
953            }
954            values
955        }
956
957        /// Look up a feature by name, as used by
958        /// [`ApplicabilityCondition::feature_name`].
959        pub fn feature(&self, name: &str) -> Option<A> {
960            match name {
961                "mean" => Some(self.mean),
962                "variance" => Some(self.variance),
963                "skewness" => self.skewness,
964                "kurtosis" => self.kurtosis,
965                "trend_slope" => self.trend_slope,
966                "trend_strength" => self.trend_strength,
967                "dominant_frequency" => self.dominant_frequency,
968                "spectral_entropy" => self.spectral_entropy,
969                "temporal_locality" => self.temporal_locality,
970                "persistence" => self.persistence,
971                "entropy" => self.entropy,
972                "fractal_dimension" => self.fractal_dimension,
973                _ => None,
974            }
975        }
976    }
977
978    /// Learned drift pattern
979    #[derive(Debug, Clone)]
980    pub struct DriftPattern<A: Float + Send + Sync> {
981        /// Pattern identifier
982        pub id: String,
983
984        /// Characteristic features
985        pub features: PatternFeatures<A>,
986
987        /// Pattern type
988        pub pattern_type: DriftType,
989
990        /// Typical duration
991        pub typical_duration: Duration,
992
993        /// Optimal adaptation strategy
994        pub optimal_adaptation: AdaptationRecommendation,
995
996        /// Success rate of this pattern's adaptations
997        pub adaptation_success_rate: A,
998
999        /// Occurrence frequency
1000        pub occurrence_count: usize,
1001    }
1002
1003    /// Feature extractor trait
1004    pub trait FeatureExtractor<A: Float + Send + Sync>: std::fmt::Debug {
1005        fn extract(&self, data: &[A]) -> A;
1006        fn name(&self) -> &str;
1007    }
1008
1009    /// Adaptive threshold management
1010    #[derive(Debug)]
1011    pub struct AdaptiveThresholdManager<A: Float + Send + Sync> {
1012        /// Current thresholds for different detectors
1013        thresholds: HashMap<String, A>,
1014
1015        /// Threshold adaptation history
1016        threshold_history: VecDeque<ThresholdUpdate<A>>,
1017
1018        /// Performance feedback for threshold adjustment
1019        performance_feedback: VecDeque<PerformanceFeedback<A>>,
1020
1021        /// Learning rate for threshold adaptation
1022        learning_rate: A,
1023    }
1024
1025    /// Threshold update record
1026    #[derive(Debug, Clone)]
1027    pub struct ThresholdUpdate<A: Float + Send + Sync> {
1028        pub detector_name: String,
1029        pub old_threshold: A,
1030        pub new_threshold: A,
1031        pub timestamp: Instant,
1032        pub reason: String,
1033    }
1034
1035    /// Performance feedback for threshold adjustment
1036    #[derive(Debug, Clone)]
1037    pub struct PerformanceFeedback<A: Float + Send + Sync> {
1038        pub true_positive_rate: A,
1039        pub false_positive_rate: A,
1040        pub detection_delay: Duration,
1041        pub adaptation_effectiveness: A,
1042        pub timestamp: Instant,
1043    }
1044
1045    /// Context-aware drift detection.
1046    ///
1047    /// Classifies each observation into a context and keeps a **private bank of
1048    /// base detectors per context**, so a stream that alternates between
1049    /// regimes does not look like drift to any of them. A single shared bank
1050    /// cannot express that: every regime switch enters its accumulators as a
1051    /// level change, so it reports drift for a stream that is perfectly
1052    /// stationary *within* each context, and conversely a real change inside
1053    /// one context is diluted by every observation belonging to the others.
1054    ///
1055    /// The banks are built by
1056    /// `impls::build_detector_bank` from the same
1057    /// [`DriftDetectorConfig`] the global bank uses, so a context detector is a
1058    /// fresh instance of the configured detector rather than a different
1059    /// algorithm. The classifier emits a fixed, small set of context ids, so
1060    /// the map is bounded by construction.
1061    #[derive(Debug)]
1062    pub struct ContextAwareDriftDetector<A: Float + Send + Sync> {
1063        /// Contextual features
1064        context_features: Vec<ContextFeature<A>>,
1065
1066        /// Current context state
1067        current_context: Option<String>,
1068
1069        /// Context transition matrix
1070        transition_matrix: HashMap<(String, String), A>,
1071
1072        /// Configuration every per-context bank is instantiated from.
1073        detector_config: DriftDetectorConfig,
1074
1075        /// One private bank of base detectors per context id.
1076        context_models: HashMap<String, Vec<Box<dyn DriftDetectorTrait<A>>>>,
1077
1078        /// Latest combined verdict of each context's own bank.
1079        context_status: HashMap<String, DriftStatus>,
1080    }
1081
1082    /// Contextual feature for drift detection
1083    #[derive(Debug, Clone)]
1084    pub struct ContextFeature<A: Float + Send + Sync> {
1085        pub name: String,
1086        pub value: A,
1087        pub importance_weight: A,
1088        pub temporal_stability: A,
1089    }
1090
1091    /// Drift impact analyzer
1092    #[derive(Debug)]
1093    pub struct DriftImpactAnalyzer<A: Float + Send + Sync> {
1094        /// Impact metrics history
1095        impact_history: VecDeque<DriftImpact<A>>,
1096
1097        /// Severity classifier
1098        severity_classifier: SeverityClassifier<A>,
1099
1100        /// Recovery time predictor
1101        recovery_predictor: RecoveryTimePredictor<A>,
1102
1103        /// Business impact estimator
1104        business_impact_estimator: BusinessImpactEstimator<A>,
1105    }
1106
1107    /// Drift impact assessment
1108    #[derive(Debug, Clone)]
1109    pub struct DriftImpact<A: Float + Send + Sync> {
1110        /// Performance degradation magnitude
1111        pub performance_degradation: A,
1112
1113        /// Affected metrics
1114        pub affected_metrics: Vec<String>,
1115
1116        /// Estimated recovery time
1117        pub estimated_recovery_time: Duration,
1118
1119        /// Confidence in impact assessment
1120        pub confidence: A,
1121
1122        /// Business impact score
1123        pub business_impact_score: A,
1124
1125        /// Urgency level
1126        pub urgency_level: UrgencyLevel,
1127    }
1128
1129    /// Urgency levels for drift response
1130    #[derive(Debug, Clone, Copy, PartialEq)]
1131    pub enum UrgencyLevel {
1132        Low,
1133        Medium,
1134        High,
1135        Critical,
1136    }
1137
1138    /// Adaptation strategy selector
1139    #[derive(Debug)]
1140    pub struct AdaptationStrategySelector<A: Float + Send + Sync> {
1141        /// Available adaptation strategies
1142        strategies: Vec<AdaptationStrategy<A>>,
1143
1144        /// Strategy performance history
1145        strategy_performance: HashMap<String, StrategyPerformance<A>>,
1146
1147        /// Multi-armed bandit for strategy selection
1148        bandit: EpsilonGreedyBandit<A>,
1149
1150        /// Context-strategy mapping
1151        context_strategy_map: HashMap<String, Vec<String>>,
1152    }
1153
1154    /// Adaptation strategy
1155    #[derive(Debug, Clone)]
1156    pub struct AdaptationStrategy<A: Float + Send + Sync> {
1157        /// Strategy identifier
1158        pub id: String,
1159
1160        /// Strategy type
1161        pub strategy_type: AdaptationStrategyType,
1162
1163        /// Parameters
1164        pub parameters: HashMap<String, A>,
1165
1166        /// Applicability conditions
1167        pub applicability_conditions: Vec<ApplicabilityCondition<A>>,
1168
1169        /// Expected effectiveness
1170        pub expected_effectiveness: A,
1171
1172        /// Computational cost
1173        pub computational_cost: A,
1174    }
1175
1176    /// Types of adaptation strategies
1177    #[derive(Debug, Clone, Copy)]
1178    pub enum AdaptationStrategyType {
1179        ParameterTuning,
1180        ModelReplacement,
1181        EnsembleReweighting,
1182        ArchitectureChange,
1183        DataAugmentation,
1184        FeatureSelection,
1185        Hybrid,
1186    }
1187
1188    /// Conditions for strategy applicability
1189    #[derive(Debug, Clone)]
1190    pub struct ApplicabilityCondition<A: Float + Send + Sync> {
1191        pub feature_name: String,
1192        pub operator: ComparisonOperator,
1193        pub threshold: A,
1194        pub weight: A,
1195    }
1196
1197    #[derive(Debug, Clone, Copy)]
1198    pub enum ComparisonOperator {
1199        GreaterThan,
1200        LessThan,
1201        Equal,
1202        NotEqual,
1203        GreaterEqual,
1204        LessEqual,
1205    }
1206
1207    /// Strategy performance tracking
1208    #[derive(Debug, Clone)]
1209    pub struct StrategyPerformance<A: Float + Send + Sync> {
1210        pub success_rate: A,
1211        pub average_improvement: A,
1212        pub average_adaptation_time: Duration,
1213        pub stability_after_adaptation: A,
1214        pub usage_count: usize,
1215    }
1216
1217    /// Epsilon-greedy bandit for strategy selection.
1218    ///
1219    /// C5: the bandit had no methods at all, so `select_strategy` returned the
1220    /// same hardcoded "increase_lr" strategy on every call.
1221    pub struct EpsilonGreedyBandit<A: Float + Send + Sync> {
1222        epsilon: A,
1223        action_values: HashMap<String, A>,
1224        action_counts: HashMap<String, usize>,
1225        total_trials: usize,
1226        /// Deterministically seeded so exploration is reproducible in tests.
1227        rng: scirs2_core::random::Random<scirs2_core::random::rngs::StdRng>,
1228    }
1229
1230    impl<A: Float + Send + Sync> std::fmt::Debug for EpsilonGreedyBandit<A> {
1231        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1232            formatter
1233                .debug_struct("EpsilonGreedyBandit")
1234                .field("action_values", &self.action_values.len())
1235                .field("total_trials", &self.total_trials)
1236                .finish()
1237        }
1238    }
1239
1240    /// Historical drift database
1241    #[derive(Debug)]
1242    pub struct DriftDatabase<A: Float + Send + Sync> {
1243        /// Stored drift events
1244        drift_events: Vec<StoredDriftEvent<A>>,
1245
1246        /// Pattern-outcome associations
1247        pattern_outcomes: HashMap<String, Vec<AdaptationOutcome<A>>>,
1248
1249        /// Seasonal drift patterns
1250        seasonal_patterns: HashMap<String, SeasonalPattern<A>>,
1251
1252        /// Similarity search index
1253        similarity_index: SimilarityIndex<A>,
1254    }
1255
1256    /// Stored drift event for learning.
1257    ///
1258    /// C5: `outcome` is now optional and starts out `None`. `store_event` used
1259    /// to invent `success: true` with a `performance_improvement` of `0.1`, a
1260    /// 60 second adaptation time and a 300 second stability period the moment
1261    /// the strategy was *selected* — before anything had been observed. The
1262    /// real outcome arrives later through
1263    /// [`AdvancedDriftDetector::record_adaptation_outcome`].
1264    #[derive(Debug, Clone)]
1265    pub struct StoredDriftEvent<A: Float + Send + Sync> {
1266        pub features: PatternFeatures<A>,
1267        pub context: Vec<ContextFeature<A>>,
1268        pub applied_strategy: String,
1269        pub outcome: Option<AdaptationOutcome<A>>,
1270        pub timestamp: Instant,
1271    }
1272
1273    /// Adaptation outcome for learning
1274    #[derive(Debug, Clone)]
1275    pub struct AdaptationOutcome<A: Float + Send + Sync> {
1276        pub success: bool,
1277        pub performance_improvement: A,
1278        pub adaptation_time: Duration,
1279        pub stability_period: Duration,
1280        pub side_effects: Vec<String>,
1281    }
1282
1283    /// Seasonal drift pattern
1284    #[derive(Debug, Clone)]
1285    pub struct SeasonalPattern<A: Float + Send + Sync> {
1286        pub period: Duration,
1287        pub amplitude: A,
1288        pub phase_offset: Duration,
1289        pub pattern_strength: A,
1290        pub last_occurrence: Instant,
1291    }
1292
1293    /// Similarity search for historical patterns
1294    #[derive(Debug)]
1295    pub struct SimilarityIndex<A: Float + Send + Sync> {
1296        /// Feature vectors for similarity search
1297        feature_vectors: Vec<(String, Vec<A>)>,
1298
1299        /// Similarity threshold
1300        similarity_threshold: A,
1301
1302        /// Distance metric
1303        distance_metric: DistanceMetric,
1304    }
1305
1306    #[derive(Debug, Clone, Copy)]
1307    pub enum DistanceMetric {
1308        Euclidean,
1309        Manhattan,
1310        Cosine,
1311        Mahalanobis,
1312    }
1313
1314    impl<A: Float + Default + Clone + std::fmt::Debug + std::iter::Sum + Send + Sync + 'static>
1315        AdvancedDriftDetector<A>
1316    {
1317        /// Create new advanced drift detector.
1318        ///
1319        /// C6/C8: `base_detectors` used to be an empty vector with an "Add base
1320        /// detectors here" comment, which made the whole detector a hollow
1321        /// shell: no detector ever voted, the adaptive thresholds had nothing to
1322        /// apply to, and `combine_detection_results` divided by
1323        /// `base_results.len() == 0`. It is now populated with adapters over the
1324        /// three real detectors implemented in this module.
1325        pub fn new(config: DriftDetectorConfig) -> Self {
1326            let threshold = A::from(config.threshold).unwrap_or_else(A::one);
1327            let warning = A::from(config.warningthreshold).unwrap_or_else(A::zero);
1328            let delta =
1329                A::from(config.alpha).unwrap_or_else(|| A::from(0.002).unwrap_or_else(A::zero));
1330
1331            let base_detectors: Vec<Box<dyn DriftDetectorTrait<A>>> = vec![
1332                Box::new(impls::PageHinkleyAdapter::new(threshold, warning)),
1333                Box::new(impls::AdwinAdapter::new(delta, config.window_size)),
1334                Box::new(impls::DdmAdapter::new(config.min_samples)),
1335            ];
1336
1337            Self {
1338                base_detectors,
1339                pattern_analyzer: DriftPatternAnalyzer::new(config.window_size),
1340                threshold_manager: AdaptiveThresholdManager::new(),
1341                context_detector: ContextAwareDriftDetector::new(config.clone()),
1342                impact_analyzer: DriftImpactAnalyzer::new(),
1343                adaptation_selector: AdaptationStrategySelector::new(),
1344                drift_database: DriftDatabase::new(),
1345            }
1346        }
1347
1348        /// The context-aware detector, for the per-context verdicts.
1349        ///
1350        /// `detect_drift_advanced` reports one combined status for the stream;
1351        /// this is how a caller reaches the verdict each context's *own*
1352        /// detector bank reached from only that context's observations, plus
1353        /// the observed context transitions.
1354        pub fn context_detector(&self) -> &ContextAwareDriftDetector<A> {
1355            &self.context_detector
1356        }
1357
1358        /// Advanced drift detection with pattern analysis
1359        pub fn detect_drift_advanced(
1360            &mut self,
1361            value: A,
1362            context_features: &[ContextFeature<A>],
1363        ) -> Result<AdvancedDriftResult<A>> {
1364            // Update context
1365            self.context_detector.update_context(context_features);
1366
1367            // Run base detectors
1368            let base_results: Vec<_> = self
1369                .base_detectors
1370                .iter_mut()
1371                .map(|detector| (detector.name().to_string(), detector.update(value)))
1372                .collect();
1373            let mut statuses: Vec<DriftStatus> =
1374                base_results.iter().map(|(_, status)| *status).collect();
1375
1376            // Run the current context's *own* bank of detectors on the same
1377            // observation. Their verdicts join the vote only once the stream has
1378            // actually shown more than one context: with a single context the
1379            // per-context bank has seen exactly the observations the global one
1380            // has, so its verdict would be a duplicate of evidence already
1381            // counted, not new evidence. From the second context onwards the two
1382            // views genuinely differ — the global bank sees the regime switches,
1383            // the context bank does not — and the difference is the whole point
1384            // of keeping per-context state.
1385            let context_statuses = self.context_detector.observe_in_context(value);
1386            if self.context_detector.context_count() > 1 {
1387                statuses.extend(context_statuses);
1388            }
1389
1390            // Analyze patterns over the rolling window (C4: the analyzer used to
1391            // be handed a one-element slice, so variance was always exactly 0
1392            // and `entropy = variance.ln().abs()` was always `+inf`).
1393            let pattern_features = self.pattern_analyzer.ingest(value)?;
1394            let matched_pattern = self.pattern_analyzer.match_pattern(&pattern_features);
1395
1396            // Adaptive threshold adjustment, then actually apply the adapted
1397            // thresholds to the detectors (C6).
1398            self.threshold_manager
1399                .update_thresholds(&base_results, &pattern_features);
1400            self.threshold_manager.apply_to(&mut self.base_detectors);
1401
1402            // Combine results with confidence weighting
1403            let combined_result = self.combine_detection_results(&statuses, &matched_pattern);
1404
1405            // Analyze impact if drift detected
1406            let impact = if combined_result.status == DriftStatus::Drift {
1407                Some(
1408                    self.impact_analyzer
1409                        .analyze_impact(&pattern_features, &matched_pattern)?,
1410                )
1411            } else {
1412                None
1413            };
1414
1415            // Select adaptation strategy
1416            let adaptation_strategy = if let Some(ref impact) = impact {
1417                self.adaptation_selector.select_strategy(
1418                    &pattern_features,
1419                    impact,
1420                    &matched_pattern,
1421                )?
1422            } else {
1423                None
1424            };
1425
1426            // Store in database for learning. The stored event carries no
1427            // outcome yet (C5): a real one arrives through
1428            // `record_adaptation_outcome`.
1429            if combined_result.status == DriftStatus::Drift {
1430                self.drift_database.store_event(
1431                    &pattern_features,
1432                    context_features,
1433                    &adaptation_strategy,
1434                );
1435            }
1436
1437            Ok(AdvancedDriftResult {
1438                status: combined_result.status,
1439                confidence: combined_result.confidence,
1440                matched_pattern,
1441                impact,
1442                recommended_strategy: adaptation_strategy,
1443                feature_importance: self.calculate_feature_importance(&pattern_features),
1444                prediction_horizon: self.estimate_drift_duration(&pattern_features),
1445            })
1446        }
1447
1448        /// Report what actually happened after the most recently recommended
1449        /// adaptation was applied (C5).
1450        ///
1451        /// This is what turns `DriftDatabase` into a real learning store: the
1452        /// outcome is recorded against the pending event, folded into the
1453        /// strategy's measured performance and the bandit's action values, and
1454        /// used to learn (or reinforce) a `DriftPattern` so that
1455        /// `match_pattern` can eventually match something.
1456        pub fn record_adaptation_outcome(&mut self, outcome: AdaptationOutcome<A>) -> Result<()> {
1457            let Some((strategy_id, features)) =
1458                self.drift_database.complete_pending_event(outcome.clone())
1459            else {
1460                return Err(crate::error::OptimError::InvalidState(
1461                    "no adaptation is awaiting an outcome".to_string(),
1462                ));
1463            };
1464            self.adaptation_selector
1465                .record_outcome(&strategy_id, &outcome);
1466            self.pattern_analyzer.learn_pattern(
1467                &features,
1468                &strategy_id,
1469                &outcome,
1470                self.impact_analyzer.last_drift_type(),
1471            );
1472            self.impact_analyzer.record_observed_recovery(&outcome);
1473            Ok(())
1474        }
1475
1476        /// Feed measured detection quality back into the threshold manager (C6).
1477        pub fn record_threshold_feedback(&mut self, feedback: PerformanceFeedback<A>) {
1478            self.threshold_manager.record_feedback(feedback);
1479        }
1480
1481        /// Patterns learned so far.
1482        pub fn known_patterns(&self) -> &HashMap<String, DriftPattern<A>> {
1483            &self.pattern_analyzer.known_patterns
1484        }
1485
1486        /// Adapted thresholds currently in force, keyed by detector name.
1487        pub fn detector_thresholds(&self) -> Vec<(String, A)> {
1488            self.base_detectors
1489                .iter()
1490                .map(|detector| (detector.name().to_string(), detector.threshold()))
1491                .collect()
1492        }
1493
1494        /// Stored drift events, including the ones still awaiting an outcome.
1495        pub fn stored_events(&self) -> &[StoredDriftEvent<A>] {
1496            &self.drift_database.drift_events
1497        }
1498
1499        fn combine_detection_results(
1500            &self,
1501            base_results: &[DriftStatus],
1502            matched_pattern: &Option<DriftPattern<A>>,
1503        ) -> CombinedDetectionResult<A> {
1504            // C8: with no detectors at all there is nothing to combine, and the
1505            // old `drift_votes / base_results.len()` produced `0/0 = NaN` which
1506            // then poisoned every downstream comparison.
1507            if base_results.is_empty() {
1508                return CombinedDetectionResult {
1509                    status: DriftStatus::Stable,
1510                    confidence: A::zero(),
1511                };
1512            }
1513
1514            // Weighted voting based on detector confidence and pattern matching
1515            let drift_votes = base_results
1516                .iter()
1517                .filter(|&&s| s == DriftStatus::Drift)
1518                .count();
1519            let warning_votes = base_results
1520                .iter()
1521                .filter(|&&s| s == DriftStatus::Warning)
1522                .count();
1523
1524            // Pattern-based confidence adjustment. With no matched pattern there
1525            // is no pattern evidence either way, so the pattern term is neutral.
1526            let neutral = A::from(0.5).unwrap_or_else(A::zero);
1527            let pattern_confidence = matched_pattern
1528                .as_ref()
1529                .map(|p| p.adaptation_success_rate)
1530                .unwrap_or(neutral);
1531            let strong = A::from(0.7).unwrap_or_else(A::one);
1532
1533            let status = if drift_votes >= 2 {
1534                DriftStatus::Drift
1535            } else if warning_votes >= 2 || (drift_votes >= 1 && pattern_confidence > strong) {
1536                DriftStatus::Warning
1537            } else {
1538                DriftStatus::Stable
1539            };
1540
1541            let vote_share =
1542                A::from(drift_votes as f64 / base_results.len() as f64).unwrap_or_else(A::zero);
1543            let confidence = vote_share * pattern_confidence;
1544
1545            CombinedDetectionResult { status, confidence }
1546        }
1547
1548        fn calculate_feature_importance(
1549            &self,
1550            features: &PatternFeatures<A>,
1551        ) -> HashMap<String, A> {
1552            // Importance is the magnitude of each feature that actually has a
1553            // value, normalised so the reported weights sum to one.
1554            let mut magnitudes: Vec<(String, A)> = features
1555                .named_values()
1556                .into_iter()
1557                .filter(|(_, value)| value.is_finite())
1558                .map(|(name, value)| (name.to_string(), value.abs()))
1559                .collect();
1560            let total = magnitudes
1561                .iter()
1562                .fold(A::zero(), |acc, (_, value)| acc + *value);
1563            if total > A::zero() {
1564                for entry in magnitudes.iter_mut() {
1565                    entry.1 = entry.1 / total;
1566                }
1567            }
1568            magnitudes.into_iter().collect()
1569        }
1570
1571        fn estimate_drift_duration(&self, features: &PatternFeatures<A>) -> Duration {
1572            // Base horizon, scaled by how strong and how persistent the observed
1573            // trend is. When either is unmeasured the base horizon stands rather
1574            // than being multiplied by a placeholder zero (which used to collapse
1575            // the horizon to 0 seconds on every call, since both fields were
1576            // hardcoded zeros).
1577            let base_duration = Duration::from_secs(300);
1578            let (Some(strength), Some(persistence)) =
1579                (features.trend_strength, features.persistence)
1580            else {
1581                return base_duration;
1582            };
1583            let multiplier = (strength * persistence).to_f64().unwrap_or(1.0);
1584            if !multiplier.is_finite() || multiplier <= 0.0 {
1585                return base_duration;
1586            }
1587            let seconds = (base_duration.as_secs() as f64 * multiplier).clamp(1.0, 86_400.0);
1588            Duration::from_secs(seconds as u64)
1589        }
1590    }
1591
1592    /// Advanced drift detection result
1593    #[derive(Debug, Clone)]
1594    pub struct AdvancedDriftResult<A: Float + Send + Sync> {
1595        pub status: DriftStatus,
1596        pub confidence: A,
1597        pub matched_pattern: Option<DriftPattern<A>>,
1598        pub impact: Option<DriftImpact<A>>,
1599        pub recommended_strategy: Option<AdaptationStrategy<A>>,
1600        pub feature_importance: HashMap<String, A>,
1601        pub prediction_horizon: Duration,
1602    }
1603
1604    #[derive(Debug, Clone)]
1605    struct CombinedDetectionResult<A: Float + Send + Sync> {
1606        status: DriftStatus,
1607        confidence: A,
1608    }
1609
1610    mod impls;
1611
1612    #[cfg(test)]
1613    mod tests;
1614
1615    pub(crate) use impls::{BusinessImpactEstimator, RecoveryTimePredictor, SeverityClassifier};
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620    use super::*;
1621
1622    #[test]
1623    fn test_page_hinkley_detector() {
1624        let mut detector = PageHinkleyDetector::new(3.0f64, 2.0f64);
1625
1626        // Stable period
1627        for _ in 0..10 {
1628            let status = detector.update(0.1);
1629            assert_eq!(status, DriftStatus::Stable);
1630        }
1631
1632        // Drift period
1633        for _ in 0..5 {
1634            let status = detector.update(0.5); // Higher loss
1635            if status == DriftStatus::Drift {
1636                break;
1637            }
1638        }
1639    }
1640
1641    /// C1: a stationary (non-drifting) loss stream whose baseline is far
1642    /// from the old hardcoded `0.1` "estimated mean under H0" must not
1643    /// falsely report drift. The previous constant made `sum` accumulate
1644    /// `loss - 0.1` on every update: for a stable stream at (say) 5.0, that
1645    /// is `+4.9` every single sample, guaranteeing `test_stat` blows past
1646    /// any reasonable threshold in only a handful of updates even though
1647    /// nothing changed.
1648    #[test]
1649    fn page_hinkley_does_not_falsely_drift_on_stable_stream_away_from_0_1() {
1650        let mut detector = PageHinkleyDetector::new(5.0f64, 3.0f64);
1651
1652        // A perfectly stationary stream at loss = 5.0, far from the old
1653        // hardcoded mean_loss of 0.1.
1654        for _ in 0..200 {
1655            let status = detector.update(5.0);
1656            assert_eq!(
1657                status,
1658                DriftStatus::Stable,
1659                "C1 regression: false drift reported on a stationary stream \
1660                 whose baseline (5.0) differs from the old hardcoded mean_loss (0.1)"
1661            );
1662        }
1663    }
1664
1665    /// C1: the detector must still correctly flag a genuine regime change
1666    /// (loss step-increasing well above its established running mean),
1667    /// confirming the running-mean fix did not just make it insensitive to
1668    /// real drift.
1669    #[test]
1670    fn page_hinkley_detects_genuine_drift_away_from_0_1_baseline() {
1671        let mut detector = PageHinkleyDetector::new(5.0f64, 3.0f64);
1672
1673        // Establish a stable baseline around loss = 5.0.
1674        for _ in 0..30 {
1675            detector.update(5.0);
1676        }
1677
1678        // Sharp, sustained increase: must eventually report Drift.
1679        let mut drifted = false;
1680        for _ in 0..50 {
1681            let status = detector.update(20.0);
1682            if status == DriftStatus::Drift {
1683                drifted = true;
1684                break;
1685            }
1686        }
1687        assert!(
1688            drifted,
1689            "C1 regression: detector failed to flag a genuine sustained \
1690             increase in loss away from a non-0.1 baseline"
1691        );
1692    }
1693
1694    #[test]
1695    fn test_adwin_detector() {
1696        let mut detector = AdwinDetector::new(0.005f64, 100);
1697
1698        // Add stable values
1699        for i in 0..20 {
1700            let value = 0.1 + (i as f64) * 0.001; // Slight trend
1701            detector.update(value);
1702        }
1703
1704        // Add drift values
1705        for i in 0..10 {
1706            let value = 0.5 + (i as f64) * 0.01; // Clear change
1707            let status = detector.update(value);
1708            if status == DriftStatus::Drift {
1709                break;
1710            }
1711        }
1712    }
1713
1714    /// C2: `delta` (the detector's confidence parameter) must actually
1715    /// affect sensitivity. The previous implementation never read `delta`
1716    /// at all, so two detectors built with wildly different `delta` values
1717    /// behaved identically. A much smaller `delta` (higher required
1718    /// confidence) must be at least as slow to fire as a larger `delta` on
1719    /// the same borderline-noisy data.
1720    #[test]
1721    fn adwin_delta_affects_sensitivity() {
1722        fn feed(mut detector: AdwinDetector<f64>) -> Option<usize> {
1723            // Stable baseline noise around 1.0.
1724            for i in 0..20 {
1725                let value = 1.0 + 0.02 * ((i % 3) as f64 - 1.0);
1726                detector.update(value);
1727            }
1728            // A modest, borderline shift.
1729            for i in 0..40 {
1730                let value = 1.15 + 0.02 * ((i % 3) as f64 - 1.0);
1731                if detector.update(value) == DriftStatus::Drift {
1732                    return Some(i);
1733                }
1734            }
1735            None
1736        }
1737
1738        // A very small delta demands much higher confidence (a much larger
1739        // eps_cut) than a large delta, so it must not fire strictly sooner.
1740        let lenient = feed(AdwinDetector::new(0.5f64, 200)); // delta close to 1: low confidence required
1741        let strict = feed(AdwinDetector::new(1e-6f64, 200)); // delta tiny: very high confidence required
1742
1743        match (lenient, strict) {
1744            (Some(_), None) => {} // lenient fired, strict correctly held off: expected
1745            (Some(l), Some(s)) => assert!(
1746                s >= l,
1747                "C2 regression: stricter delta (1e-6) fired sooner ({s}) than \
1748                 lenient delta (0.5, fired at {l}) — delta has no effect on sensitivity"
1749            ),
1750            (None, Some(_)) => {
1751                panic!("C2 regression: stricter delta fired but the more lenient delta did not")
1752            }
1753            (None, None) => {
1754                // Both held off - inconclusive for the ordering claim, but
1755                // at minimum confirms neither exploded/panicked.
1756            }
1757        }
1758    }
1759
1760    #[test]
1761    fn test_ddm_detector() {
1762        let mut detector = DdmDetector::<f64>::new();
1763
1764        // Stable period with low error rate
1765        for i in 0..50 {
1766            let iserror = i % 10 == 0; // 10% error rate
1767            detector.update(iserror);
1768        }
1769
1770        // Period with high error rate
1771        for i in 0..20 {
1772            let iserror = i % 2 == 0; // 50% error rate
1773            let status = detector.update(iserror);
1774            if status == DriftStatus::Drift {
1775                break;
1776            }
1777        }
1778    }
1779
1780    #[test]
1781    fn test_concept_drift_detector() {
1782        let config = DriftDetectorConfig::default();
1783        let mut detector = ConceptDriftDetector::new(config);
1784
1785        // Simulate stable period
1786        for i in 0..30 {
1787            let loss = 0.1 + (i as f64) * 0.001;
1788            let iserror = i % 10 == 0;
1789            let status = detector.update(loss, iserror).expect("unwrap failed");
1790            assert_ne!(status, DriftStatus::Drift); // Should be stable
1791        }
1792
1793        // Simulate drift
1794        for i in 0..20 {
1795            let loss = 0.5 + (i as f64) * 0.01; // Much higher loss
1796            let iserror = i % 2 == 0; // Higher error rate
1797            let _status = detector.update(loss, iserror).expect("unwrap failed");
1798        }
1799
1800        let stats = detector.get_statistics();
1801        assert!(stats.total_drifts > 0 || stats.recent_drift_rate > 0.0);
1802    }
1803
1804    #[test]
1805    fn test_drift_event() {
1806        let event = DriftEvent {
1807            timestamp: Instant::now(),
1808            confidence: 0.85f64,
1809            drift_type: DriftType::Sudden,
1810            adaptation_recommendation: AdaptationRecommendation::Reset,
1811        };
1812
1813        assert_eq!(event.drift_type, DriftType::Sudden);
1814        assert!(event.confidence > 0.8);
1815    }
1816}