Skip to main content

optirs_learned/
realtime_adaptation.rs

1//! Online drift-triggered hyperparameter adaptation.
2//!
3//! This module implements a streaming change-point / concept-drift controller
4//! that watches the training loss and gradient-norm signals during optimization
5//! and adjusts the learning rate (and momentum) online in response to detected
6//! drift, divergence, or plateaus.
7//!
8//! Two classical change-detection statistics are combined inside
9//! [`DriftDetector`], one detector per scalar stream:
10//!
11//! - **EWMA control chart** (Roberts, 1959). The exponentially-weighted moving
12//!   average of the signal is tracked together with an EWMA estimate of its
13//!   variance, and control limits `μ ± L·σ` flag out-of-limit observations and
14//!   their direction.
15//! - **Two-sided CUSUM** (Page, 1954). Cumulative sums of the deviation from a
16//!   warm-up reference mean `μ0`, with a slack `k` (allowance) and decision
17//!   threshold `h`, detect small persistent mean shifts in either direction and
18//!   reset after raising an alarm.
19//!
20//! [`RealtimeAdaptationController`] holds one detector for the loss stream and
21//! one for the gradient-norm stream, plus the current learning rate / momentum,
22//! a cooldown counter, and a plateau tracker. Its [`observe`] method applies an
23//! additive-increase / multiplicative-decrease (AIMD) control policy:
24//!
25//! - Diverging loss or exploding gradient norm → multiplicatively *decrease* the
26//!   learning rate and damp momentum.
27//! - A loss plateau (ReduceLROnPlateau style) → *decrease* the learning rate to
28//!   refine.
29//! - Stable and improving loss with a bounded gradient norm → gently *increase*
30//!   the learning rate up to a ceiling.
31//!
32//! The learning rate is always clamped to `[lr_min, lr_max]` and momentum to
33//! `[mom_min, mom_max]`, and a `cooldown_steps` window suppresses a second
34//! adjustment immediately after any change.
35//!
36//! [`observe`]: RealtimeAdaptationController::observe
37//!
38//! # Examples
39//!
40//! ```
41//! use optirs_learned::realtime_adaptation::{
42//!     RealtimeAdaptationConfig, RealtimeAdaptationController,
43//! };
44//!
45//! let config = RealtimeAdaptationConfig::<f64>::default();
46//! let mut controller = RealtimeAdaptationController::new(config)
47//!     .expect("default config is valid");
48//! // Feed (loss, grad_norm) observations from a training loop.
49//! let decision = controller.observe(1.0, 0.5);
50//! assert!(decision.learning_rate > 0.0);
51//! ```
52
53use std::fmt::Debug;
54
55use scirs2_core::numeric::Float;
56
57use crate::domain_optimizers::AdvancedOptimizer;
58use crate::error::{OptimError, Result};
59
60/// Drift verdict produced by a [`DriftDetector`] for a single observation.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum DriftSignal {
63    /// No drift detected: the stream is in control.
64    Stable,
65    /// The stream's mean has drifted upward (a positive shift).
66    UpwardDrift,
67    /// The stream's mean has drifted downward (a negative shift).
68    DownwardDrift,
69}
70
71/// Configuration for a single-stream [`DriftDetector`].
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct DriftDetectorConfig<T>
74where
75    T: Float + Debug + Send + Sync + 'static,
76{
77    /// EWMA smoothing factor `λ ∈ (0, 1]`. Larger values weight recent
78    /// observations more heavily.
79    pub lambda: T,
80    /// Control-limit width `L` (in EWMA standard deviations) for the EWMA chart.
81    pub control_limit: T,
82    /// CUSUM slack / allowance `k`, expressed in reference standard deviations.
83    pub cusum_slack: T,
84    /// CUSUM decision threshold `h`, expressed in reference standard deviations.
85    pub cusum_threshold: T,
86    /// Number of warm-up observations used to estimate the reference mean and
87    /// standard deviation before any drift is reported.
88    pub warmup_steps: usize,
89    /// Floor on the reference / EWMA standard deviation, guarding against a
90    /// degenerate zero-variance warm-up window.
91    pub min_std: T,
92}
93
94impl<T> DriftDetectorConfig<T>
95where
96    T: Float + Debug + Send + Sync + 'static,
97{
98    /// Validate the configuration, returning an honest error for any field that
99    /// would make the detector ill-defined.
100    pub fn validate(&self) -> Result<()> {
101        if !(self.lambda > T::zero() && self.lambda <= T::one()) {
102            return Err(OptimError::InvalidConfig(format!(
103                "DriftDetector lambda must lie in (0, 1], got {:?}",
104                self.lambda
105            )));
106        }
107        if self.control_limit <= T::zero() {
108            return Err(OptimError::InvalidConfig(format!(
109                "DriftDetector control_limit must be positive, got {:?}",
110                self.control_limit
111            )));
112        }
113        if self.cusum_slack <= T::zero() {
114            return Err(OptimError::InvalidConfig(format!(
115                "DriftDetector cusum_slack must be positive, got {:?}",
116                self.cusum_slack
117            )));
118        }
119        if self.cusum_threshold <= T::zero() {
120            return Err(OptimError::InvalidConfig(format!(
121                "DriftDetector cusum_threshold must be positive, got {:?}",
122                self.cusum_threshold
123            )));
124        }
125        if self.warmup_steps == 0 {
126            return Err(OptimError::InvalidConfig(
127                "DriftDetector warmup_steps must be at least 1".to_string(),
128            ));
129        }
130        if self.min_std <= T::zero() {
131            return Err(OptimError::InvalidConfig(format!(
132                "DriftDetector min_std must be positive, got {:?}",
133                self.min_std
134            )));
135        }
136        Ok(())
137    }
138}
139
140impl<T> Default for DriftDetectorConfig<T>
141where
142    T: Float + Debug + Send + Sync + 'static,
143{
144    fn default() -> Self {
145        let from = |v: f64, fallback: T| T::from(v).unwrap_or(fallback);
146        Self {
147            lambda: from(0.2, T::one()),
148            control_limit: from(3.0, T::one()),
149            cusum_slack: from(0.5, T::one()),
150            cusum_threshold: from(5.0, T::one()),
151            warmup_steps: 20,
152            min_std: from(1e-8, T::epsilon()),
153        }
154    }
155}
156
157/// A single-stream online drift detector combining an EWMA control chart with a
158/// two-sided CUSUM statistic.
159///
160/// Construct one with [`DriftDetector::new`] and feed it scalar observations via
161/// [`DriftDetector::update`]. During the first `warmup_steps` observations the
162/// detector estimates the reference mean `μ0` and standard deviation `σ0` and
163/// reports [`DriftSignal::Stable`]. Thereafter each update returns a
164/// [`DriftSignal`] derived from both statistics.
165#[derive(Debug, Clone)]
166pub struct DriftDetector<T>
167where
168    T: Float + Debug + Send + Sync + 'static,
169{
170    /// Validated configuration.
171    config: DriftDetectorConfig<T>,
172    /// Number of observations seen so far.
173    count: usize,
174    /// Running sum of warm-up observations (for `μ0`).
175    warmup_sum: T,
176    /// Running sum of squares of warm-up observations (for `σ0`).
177    warmup_sum_sq: T,
178    /// Reference mean `μ0`, finalised at the end of warm-up.
179    reference_mean: T,
180    /// Reference standard deviation `σ0`, finalised at the end of warm-up.
181    reference_std: T,
182    /// Whether warm-up has completed and `μ0` / `σ0` are valid.
183    warmed_up: bool,
184    /// Current EWMA mean `μ_t`.
185    ewma_mean: T,
186    /// Current EWMA variance estimate `σ²_t`.
187    ewma_var: T,
188    /// Positive CUSUM accumulator `S⁺`.
189    cusum_pos: T,
190    /// Negative CUSUM accumulator `S⁻`.
191    cusum_neg: T,
192}
193
194impl<T> DriftDetector<T>
195where
196    T: Float + Debug + Send + Sync + 'static,
197{
198    /// Create a new detector from a configuration.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`OptimError::InvalidConfig`] if [`DriftDetectorConfig::validate`]
203    /// fails.
204    pub fn new(config: DriftDetectorConfig<T>) -> Result<Self> {
205        config.validate()?;
206        Ok(Self {
207            config,
208            count: 0,
209            warmup_sum: T::zero(),
210            warmup_sum_sq: T::zero(),
211            reference_mean: T::zero(),
212            reference_std: T::zero(),
213            warmed_up: false,
214            ewma_mean: T::zero(),
215            ewma_var: T::zero(),
216            cusum_pos: T::zero(),
217            cusum_neg: T::zero(),
218        })
219    }
220
221    /// Finalise the reference mean and standard deviation from the warm-up
222    /// accumulators and seed the EWMA chart at `μ0`.
223    fn finalize_warmup(&mut self) {
224        let n = T::from(self.count).unwrap_or_else(T::one);
225        let mean = self.warmup_sum / n;
226        // Population variance over the warm-up window: E[x^2] - E[x]^2, floored
227        // at zero before taking the root to avoid spurious NaNs from rounding.
228        let mean_sq = self.warmup_sum_sq / n;
229        let raw_var = mean_sq - mean * mean;
230        let var = if raw_var > T::zero() {
231            raw_var
232        } else {
233            T::zero()
234        };
235        let std = var.sqrt();
236        self.reference_mean = mean;
237        self.reference_std = if std > self.config.min_std {
238            std
239        } else {
240            self.config.min_std
241        };
242        // Seed the EWMA at the reference estimates so the first post-warm-up
243        // control limits are meaningful.
244        self.ewma_mean = mean;
245        self.ewma_var = self.reference_std * self.reference_std;
246        self.warmed_up = true;
247    }
248
249    /// Feed one observation and obtain the drift verdict.
250    pub fn update(&mut self, x: T) -> DriftSignal {
251        self.count = self.count.saturating_add(1);
252
253        if !self.warmed_up {
254            self.warmup_sum = self.warmup_sum + x;
255            self.warmup_sum_sq = self.warmup_sum_sq + x * x;
256            if self.count >= self.config.warmup_steps {
257                self.finalize_warmup();
258            }
259            return DriftSignal::Stable;
260        }
261
262        let lambda = self.config.lambda;
263        let one_minus = T::one() - lambda;
264
265        // EWMA variance uses the *prior* mean for the deviation term (Roberts'
266        // recursion), so compute it before updating the mean.
267        let prev_mean = self.ewma_mean;
268        let deviation = x - prev_mean;
269        self.ewma_var = lambda * (deviation * deviation) + one_minus * self.ewma_var;
270        self.ewma_mean = lambda * x + one_minus * prev_mean;
271
272        let ewma_std = {
273            let s = self.ewma_var.sqrt();
274            if s > self.config.min_std {
275                s
276            } else {
277                self.config.min_std
278            }
279        };
280        // `ewma_std` tracks the *raw signal's* standard deviation, but the
281        // statistic being bounded is `ewma_mean` itself (the smoothed
282        // statistic), whose steady-state variance is reduced by a factor
283        // `lambda / (2 - lambda)` relative to the raw signal (Roberts 1959;
284        // Lucas & Saccucci 1990). Without this asymptotic factor the control
285        // limit is scaled to the wrong (much larger) standard deviation, so
286        // the chart under-reacts to genuine shifts by roughly
287        // `sqrt((2 - lambda) / lambda)` (e.g. ~3x too wide at the default
288        // lambda = 0.2).
289        let two: T = scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(T::one);
290        let steady_state_factor = (lambda / (two - lambda)).sqrt();
291        let limit = self.config.control_limit * ewma_std * steady_state_factor;
292        let upper = self.reference_mean + limit;
293        let lower = self.reference_mean - limit;
294        let ewma_breach_up = self.ewma_mean > upper;
295        let ewma_breach_down = self.ewma_mean < lower;
296
297        // Two-sided CUSUM on the standardised deviation from the reference mean.
298        let std = self.reference_std;
299        let k = self.config.cusum_slack * std;
300        let h = self.config.cusum_threshold * std;
301        let centered = x - self.reference_mean;
302        // S⁺_t = max(0, S⁺_{t-1} + (x - μ0 - k))
303        self.cusum_pos = (self.cusum_pos + (centered - k)).max(T::zero());
304        // S⁻_t = max(0, S⁻_{t-1} - (x - μ0 + k))  (accumulates downward shifts)
305        self.cusum_neg = (self.cusum_neg - (centered + k)).max(T::zero());
306
307        let cusum_alarm_up = self.cusum_pos > h;
308        let cusum_alarm_down = self.cusum_neg > h;
309
310        // Combine the two statistics. A CUSUM alarm is the primary, persistent
311        // mean-shift signal; an EWMA breach on the same side reinforces it, and
312        // an EWMA breach alone also raises a (more reactive) signal. Resolve the
313        // direction by whichever side has the stronger evidence.
314        let up = cusum_alarm_up || ewma_breach_up;
315        let down = cusum_alarm_down || ewma_breach_down;
316
317        let signal = match (up, down) {
318            (true, false) => DriftSignal::UpwardDrift,
319            (false, true) => DriftSignal::DownwardDrift,
320            (true, true) => {
321                // Conflicting one-sided evidence: pick the larger CUSUM
322                // excursion, falling back to the EWMA mean's side.
323                if self.cusum_pos >= self.cusum_neg {
324                    DriftSignal::UpwardDrift
325                } else {
326                    DriftSignal::DownwardDrift
327                }
328            }
329            (false, false) => DriftSignal::Stable,
330        };
331
332        // Reset the alarming accumulator(s) so the detector re-arms after a
333        // change point, as prescribed for CUSUM monitoring.
334        if cusum_alarm_up {
335            self.cusum_pos = T::zero();
336        }
337        if cusum_alarm_down {
338            self.cusum_neg = T::zero();
339        }
340
341        signal
342    }
343
344    /// Reset the detector to its just-constructed state (warm-up restarts).
345    pub fn reset(&mut self) {
346        self.count = 0;
347        self.warmup_sum = T::zero();
348        self.warmup_sum_sq = T::zero();
349        self.reference_mean = T::zero();
350        self.reference_std = T::zero();
351        self.warmed_up = false;
352        self.ewma_mean = T::zero();
353        self.ewma_var = T::zero();
354        self.cusum_pos = T::zero();
355        self.cusum_neg = T::zero();
356    }
357
358    /// Whether warm-up has completed.
359    pub fn is_warmed_up(&self) -> bool {
360        self.warmed_up
361    }
362
363    /// Current EWMA mean `μ_t`.
364    pub fn ewma_mean(&self) -> T {
365        self.ewma_mean
366    }
367
368    /// Current EWMA standard deviation `√σ²_t` (floored at `min_std`).
369    pub fn ewma_std(&self) -> T {
370        let s = self.ewma_var.sqrt();
371        if s > self.config.min_std {
372            s
373        } else {
374            self.config.min_std
375        }
376    }
377
378    /// Reference mean `μ0` estimated during warm-up.
379    pub fn reference_mean(&self) -> T {
380        self.reference_mean
381    }
382
383    /// Reference standard deviation `σ0` estimated during warm-up.
384    pub fn reference_std(&self) -> T {
385        self.reference_std
386    }
387
388    /// Current positive CUSUM accumulator `S⁺`.
389    pub fn cusum_pos(&self) -> T {
390        self.cusum_pos
391    }
392
393    /// Current negative CUSUM accumulator `S⁻`.
394    pub fn cusum_neg(&self) -> T {
395        self.cusum_neg
396    }
397
398    /// Number of observations seen so far.
399    pub fn count(&self) -> usize {
400        self.count
401    }
402}
403
404/// Why the controller produced a particular adaptation decision.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum AdaptationReason {
407    /// No change: still warming up, in cooldown, or no actionable signal.
408    NoChange,
409    /// Learning rate decreased because the loss is diverging.
410    LossDivergence,
411    /// Learning rate decreased because the gradient norm is exploding.
412    GradientExplosion,
413    /// Learning rate decreased because the loss has plateaued.
414    Plateau,
415    /// Learning rate increased because the loss is stably improving.
416    StableImprovement,
417}
418
419impl AdaptationReason {
420    /// A human-readable description of the reason.
421    pub fn as_str(&self) -> &'static str {
422        match self {
423            AdaptationReason::NoChange => "no_change",
424            AdaptationReason::LossDivergence => "loss_divergence",
425            AdaptationReason::GradientExplosion => "gradient_explosion",
426            AdaptationReason::Plateau => "plateau",
427            AdaptationReason::StableImprovement => "stable_improvement",
428        }
429    }
430}
431
432/// The decision returned by [`RealtimeAdaptationController::observe`].
433#[derive(Debug, Clone)]
434pub struct AdaptationDecision<T>
435where
436    T: Float + Debug + Send + Sync + 'static,
437{
438    /// Learning rate to use after this observation (already clamped).
439    pub learning_rate: T,
440    /// Momentum to use after this observation (already clamped).
441    pub momentum: T,
442    /// Whether any drift was detected on either monitored stream this step.
443    pub drift_detected: bool,
444    /// The categorical reason for the decision.
445    pub reason: AdaptationReason,
446}
447
448/// Configuration for the [`RealtimeAdaptationController`].
449#[derive(Debug, Clone, Copy, PartialEq)]
450pub struct RealtimeAdaptationConfig<T>
451where
452    T: Float + Debug + Send + Sync + 'static,
453{
454    /// Detector configuration for the loss stream.
455    pub loss_detector: DriftDetectorConfig<T>,
456    /// Detector configuration for the gradient-norm stream.
457    pub grad_detector: DriftDetectorConfig<T>,
458    /// Initial learning rate.
459    pub initial_lr: T,
460    /// Lower clamp on the learning rate.
461    pub lr_min: T,
462    /// Upper clamp on the learning rate.
463    pub lr_max: T,
464    /// Multiplicative factor `< 1` applied to decrease the learning rate.
465    pub lr_decay: T,
466    /// Multiplicative factor `> 1` applied to increase the learning rate.
467    pub lr_growth: T,
468    /// Initial momentum.
469    pub initial_momentum: T,
470    /// Lower clamp on momentum.
471    pub mom_min: T,
472    /// Upper clamp on momentum.
473    pub mom_max: T,
474    /// Multiplicative factor `<= 1` used to damp momentum on divergence.
475    pub mom_damp: T,
476    /// Number of consecutive low-improvement loss steps that constitute a
477    /// plateau.
478    pub plateau_patience: usize,
479    /// Relative-improvement tolerance below which a loss step counts as "no
480    /// progress" for plateau detection.
481    pub plateau_tol: T,
482    /// Number of steps after any adjustment during which no new adjustment is
483    /// made.
484    pub cooldown_steps: usize,
485    /// Multiplier on the gradient-norm reference mean above which the gradient
486    /// is considered "unbounded" (suppressing learning-rate increases).
487    pub grad_norm_ceiling_factor: T,
488}
489
490impl<T> RealtimeAdaptationConfig<T>
491where
492    T: Float + Debug + Send + Sync + 'static,
493{
494    /// Validate the controller configuration and both detector configurations.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`OptimError::InvalidConfig`] describing the first malformed
499    /// field encountered.
500    pub fn validate(&self) -> Result<()> {
501        self.loss_detector.validate()?;
502        self.grad_detector.validate()?;
503
504        if self.lr_min <= T::zero() {
505            return Err(OptimError::InvalidConfig(format!(
506                "lr_min must be positive, got {:?}",
507                self.lr_min
508            )));
509        }
510        if self.lr_min > self.lr_max {
511            return Err(OptimError::InvalidConfig(format!(
512                "lr_min ({:?}) must not exceed lr_max ({:?})",
513                self.lr_min, self.lr_max
514            )));
515        }
516        if !(self.initial_lr >= self.lr_min && self.initial_lr <= self.lr_max) {
517            return Err(OptimError::InvalidConfig(format!(
518                "initial_lr ({:?}) must lie within [lr_min, lr_max] = [{:?}, {:?}]",
519                self.initial_lr, self.lr_min, self.lr_max
520            )));
521        }
522        if !(self.lr_decay > T::zero() && self.lr_decay < T::one()) {
523            return Err(OptimError::InvalidConfig(format!(
524                "lr_decay must lie in (0, 1), got {:?}",
525                self.lr_decay
526            )));
527        }
528        if self.lr_growth <= T::one() {
529            return Err(OptimError::InvalidConfig(format!(
530                "lr_growth must be greater than 1, got {:?}",
531                self.lr_growth
532            )));
533        }
534        if self.mom_min > self.mom_max {
535            return Err(OptimError::InvalidConfig(format!(
536                "mom_min ({:?}) must not exceed mom_max ({:?})",
537                self.mom_min, self.mom_max
538            )));
539        }
540        if !(self.mom_min >= T::zero() && self.mom_max < T::one()) {
541            return Err(OptimError::InvalidConfig(format!(
542                "momentum bounds must satisfy 0 <= mom_min and mom_max < 1, got [{:?}, {:?}]",
543                self.mom_min, self.mom_max
544            )));
545        }
546        if !(self.initial_momentum >= self.mom_min && self.initial_momentum <= self.mom_max) {
547            return Err(OptimError::InvalidConfig(format!(
548                "initial_momentum ({:?}) must lie within [mom_min, mom_max] = [{:?}, {:?}]",
549                self.initial_momentum, self.mom_min, self.mom_max
550            )));
551        }
552        if !(self.mom_damp > T::zero() && self.mom_damp <= T::one()) {
553            return Err(OptimError::InvalidConfig(format!(
554                "mom_damp must lie in (0, 1], got {:?}",
555                self.mom_damp
556            )));
557        }
558        if self.plateau_patience == 0 {
559            return Err(OptimError::InvalidConfig(
560                "plateau_patience must be at least 1".to_string(),
561            ));
562        }
563        if self.plateau_tol < T::zero() {
564            return Err(OptimError::InvalidConfig(format!(
565                "plateau_tol must be non-negative, got {:?}",
566                self.plateau_tol
567            )));
568        }
569        if self.grad_norm_ceiling_factor <= T::one() {
570            return Err(OptimError::InvalidConfig(format!(
571                "grad_norm_ceiling_factor must be greater than 1, got {:?}",
572                self.grad_norm_ceiling_factor
573            )));
574        }
575        Ok(())
576    }
577}
578
579impl<T> Default for RealtimeAdaptationConfig<T>
580where
581    T: Float + Debug + Send + Sync + 'static,
582{
583    fn default() -> Self {
584        let from = |v: f64, fallback: T| T::from(v).unwrap_or(fallback);
585        Self {
586            loss_detector: DriftDetectorConfig::default(),
587            grad_detector: DriftDetectorConfig::default(),
588            initial_lr: from(1e-3, T::epsilon()),
589            lr_min: from(1e-6, T::epsilon()),
590            lr_max: from(1e-1, T::one()),
591            lr_decay: from(0.5, T::one()),
592            lr_growth: from(1.1, T::one() + T::one()),
593            initial_momentum: from(0.9, T::one()),
594            mom_min: from(0.5, T::zero()),
595            mom_max: from(0.99, T::one()),
596            mom_damp: from(0.9, T::one()),
597            plateau_patience: 10,
598            plateau_tol: from(1e-4, T::epsilon()),
599            cooldown_steps: 5,
600            grad_norm_ceiling_factor: from(3.0, T::one() + T::one()),
601        }
602    }
603}
604
605/// Online controller that adapts the learning rate and momentum in response to
606/// drift detected on the loss and gradient-norm streams.
607#[derive(Debug, Clone)]
608pub struct RealtimeAdaptationController<T>
609where
610    T: Float + Debug + Send + Sync + 'static,
611{
612    /// Validated configuration.
613    config: RealtimeAdaptationConfig<T>,
614    /// Drift detector for the loss stream.
615    loss_detector: DriftDetector<T>,
616    /// Drift detector for the gradient-norm stream.
617    grad_detector: DriftDetector<T>,
618    /// Current learning rate.
619    learning_rate: T,
620    /// Current momentum.
621    momentum: T,
622    /// Steps remaining in the post-adjustment cooldown window.
623    cooldown_remaining: usize,
624    /// EWMA of the loss used for plateau detection.
625    plateau_loss_ema: T,
626    /// Whether `plateau_loss_ema` has been initialised.
627    plateau_initialised: bool,
628    /// Number of consecutive low-improvement steps seen so far.
629    plateau_counter: usize,
630    /// Total observations processed.
631    step_count: usize,
632}
633
634impl<T> RealtimeAdaptationController<T>
635where
636    T: Float + Debug + Send + Sync + 'static,
637{
638    /// Build a controller from a configuration.
639    ///
640    /// # Errors
641    ///
642    /// Returns [`OptimError::InvalidConfig`] if
643    /// [`RealtimeAdaptationConfig::validate`] fails.
644    pub fn new(config: RealtimeAdaptationConfig<T>) -> Result<Self> {
645        config.validate()?;
646        let loss_detector = DriftDetector::new(config.loss_detector)?;
647        let grad_detector = DriftDetector::new(config.grad_detector)?;
648        let learning_rate = config.initial_lr;
649        let momentum = config.initial_momentum;
650        Ok(Self {
651            config,
652            loss_detector,
653            grad_detector,
654            learning_rate,
655            momentum,
656            cooldown_remaining: 0,
657            plateau_loss_ema: T::zero(),
658            plateau_initialised: false,
659            plateau_counter: 0,
660            step_count: 0,
661        })
662    }
663
664    /// Current learning rate.
665    pub fn learning_rate(&self) -> T {
666        self.learning_rate
667    }
668
669    /// Current momentum.
670    pub fn momentum(&self) -> T {
671        self.momentum
672    }
673
674    /// Number of observations processed so far.
675    pub fn step_count(&self) -> usize {
676        self.step_count
677    }
678
679    /// Steps remaining in the current cooldown window (zero when not cooling).
680    pub fn cooldown_remaining(&self) -> usize {
681        self.cooldown_remaining
682    }
683
684    /// Immutable access to the loss-stream detector.
685    pub fn loss_detector(&self) -> &DriftDetector<T> {
686        &self.loss_detector
687    }
688
689    /// Immutable access to the gradient-norm-stream detector.
690    pub fn grad_detector(&self) -> &DriftDetector<T> {
691        &self.grad_detector
692    }
693
694    /// Clamp a learning-rate candidate to `[lr_min, lr_max]`.
695    fn clamp_lr(&self, lr: T) -> T {
696        if lr < self.config.lr_min {
697            self.config.lr_min
698        } else if lr > self.config.lr_max {
699            self.config.lr_max
700        } else {
701            lr
702        }
703    }
704
705    /// Clamp a momentum candidate to `[mom_min, mom_max]`.
706    fn clamp_mom(&self, mom: T) -> T {
707        if mom < self.config.mom_min {
708            self.config.mom_min
709        } else if mom > self.config.mom_max {
710            self.config.mom_max
711        } else {
712            mom
713        }
714    }
715
716    /// Update the plateau tracker with the latest loss and return whether the
717    /// loss has plateaued (no meaningful relative improvement for
718    /// `plateau_patience` consecutive steps).
719    fn update_plateau(&mut self, loss: T) -> bool {
720        let lambda = self.config.loss_detector.lambda;
721        let one_minus = T::one() - lambda;
722
723        if !self.plateau_initialised {
724            self.plateau_loss_ema = loss;
725            self.plateau_initialised = true;
726            self.plateau_counter = 0;
727            return false;
728        }
729
730        let prev = self.plateau_loss_ema;
731        let new_ema = lambda * loss + one_minus * prev;
732        // Relative improvement of the EWMA loss. Positive means the loss went
733        // down. We normalise by |prev| (with a small floor) so the tolerance is
734        // scale-free.
735        let denom = {
736            let a = prev.abs();
737            if a > self.config.loss_detector.min_std {
738                a
739            } else {
740                self.config.loss_detector.min_std
741            }
742        };
743        let rel_improvement = (prev - new_ema) / denom;
744        self.plateau_loss_ema = new_ema;
745
746        if rel_improvement < self.config.plateau_tol {
747            // Not enough progress (this also covers loss increases).
748            self.plateau_counter = self.plateau_counter.saturating_add(1);
749        } else {
750            self.plateau_counter = 0;
751        }
752
753        self.plateau_counter >= self.config.plateau_patience
754    }
755
756    /// Whether the gradient norm is currently within its "bounded" envelope,
757    /// i.e. its EWMA mean is below `grad_norm_ceiling_factor · μ0`. Before the
758    /// gradient detector warms up we optimistically treat the gradient as
759    /// bounded so early stable-improvement increases are not blocked.
760    fn grad_norm_bounded(&self) -> bool {
761        if !self.grad_detector.is_warmed_up() {
762            return true;
763        }
764        let ceiling = self.config.grad_norm_ceiling_factor * self.grad_detector.reference_mean();
765        self.grad_detector.ewma_mean() <= ceiling
766    }
767
768    /// Process one `(loss, grad_norm)` observation and adapt the hyperparameters.
769    ///
770    /// The two detectors are always updated (so their internal statistics stay
771    /// current even during cooldown), but at most one learning-rate adjustment
772    /// is applied per call, and none while a cooldown window is active.
773    pub fn observe(&mut self, loss: T, grad_norm: T) -> AdaptationDecision<T> {
774        self.step_count = self.step_count.saturating_add(1);
775
776        let loss_signal = self.loss_detector.update(loss);
777        let grad_signal = self.grad_detector.update(grad_norm);
778        let plateaued = self.update_plateau(loss);
779
780        let drift_detected =
781            loss_signal != DriftSignal::Stable || grad_signal != DriftSignal::Stable;
782
783        // Respect the cooldown window: decrement and emit a no-change decision.
784        if self.cooldown_remaining > 0 {
785            self.cooldown_remaining -= 1;
786            return AdaptationDecision {
787                learning_rate: self.learning_rate,
788                momentum: self.momentum,
789                drift_detected,
790                reason: AdaptationReason::NoChange,
791            };
792        }
793
794        // Priority order: divergence / explosion (safety) first, then plateau,
795        // then opportunistic growth.
796        let loss_diverging = loss_signal == DriftSignal::UpwardDrift;
797        let grad_exploding = grad_signal == DriftSignal::UpwardDrift;
798        let loss_improving = loss_signal == DriftSignal::DownwardDrift;
799
800        let reason = if loss_diverging || grad_exploding {
801            // Multiplicative decrease + momentum damping.
802            self.learning_rate = self.clamp_lr(self.learning_rate * self.config.lr_decay);
803            self.momentum = self.clamp_mom(self.momentum * self.config.mom_damp);
804            if loss_diverging {
805                AdaptationReason::LossDivergence
806            } else {
807                AdaptationReason::GradientExplosion
808            }
809        } else if plateaued {
810            // ReduceLROnPlateau-style decrease to refine.
811            self.learning_rate = self.clamp_lr(self.learning_rate * self.config.lr_decay);
812            // Reset the plateau counter so we wait another full patience window
813            // before reducing again.
814            self.plateau_counter = 0;
815            AdaptationReason::Plateau
816        } else if loss_improving && self.grad_norm_bounded() {
817            // Additive-increase flavour: gently grow towards the ceiling.
818            let grown = self.learning_rate * self.config.lr_growth;
819            self.learning_rate = self.clamp_lr(grown);
820            AdaptationReason::StableImprovement
821        } else {
822            AdaptationReason::NoChange
823        };
824
825        // Engage cooldown only if we actually adjusted.
826        if reason != AdaptationReason::NoChange {
827            self.cooldown_remaining = self.config.cooldown_steps;
828        }
829
830        AdaptationDecision {
831            learning_rate: self.learning_rate,
832            momentum: self.momentum,
833            drift_detected,
834            reason,
835        }
836    }
837
838    /// Push the controller's current learning rate onto an
839    /// [`AdvancedOptimizer`], a thin bridge for wiring the controller into a
840    /// training loop.
841    pub fn apply_to(&self, optimizer: &mut dyn AdvancedOptimizer<T>) {
842        optimizer.set_learning_rate(self.learning_rate);
843    }
844
845    /// Reset the controller and both detectors to their initial state.
846    pub fn reset(&mut self) {
847        self.loss_detector.reset();
848        self.grad_detector.reset();
849        self.learning_rate = self.config.initial_lr;
850        self.momentum = self.config.initial_momentum;
851        self.cooldown_remaining = 0;
852        self.plateau_loss_ema = T::zero();
853        self.plateau_initialised = false;
854        self.plateau_counter = 0;
855        self.step_count = 0;
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use crate::domain_optimizers::{AdvancedOptimizer, OptimizerStateInfo};
863    use scirs2_core::ndarray::Array1;
864
865    fn detector_config() -> DriftDetectorConfig<f64> {
866        DriftDetectorConfig {
867            lambda: 0.2,
868            control_limit: 3.0,
869            cusum_slack: 0.5,
870            cusum_threshold: 5.0,
871            warmup_steps: 20,
872            min_std: 1e-8,
873        }
874    }
875
876    /// A tiny deterministic SGD optimizer used to exercise `apply_to`.
877    struct DummyOptimizer {
878        lr: f64,
879    }
880
881    impl AdvancedOptimizer<f64> for DummyOptimizer {
882        fn step(&mut self, params: &Array1<f64>, gradients: &Array1<f64>) -> Result<Array1<f64>> {
883            Ok(params - &gradients.mapv(|g| g * self.lr))
884        }
885        fn get_learning_rate(&self) -> f64 {
886            self.lr
887        }
888        fn set_learning_rate(&mut self, lr: f64) {
889            self.lr = lr;
890        }
891        fn name(&self) -> &str {
892            "DummyOptimizer"
893        }
894        fn get_state(&self) -> OptimizerStateInfo<f64> {
895            OptimizerStateInfo {
896                step_count: 0,
897                current_lr: self.lr,
898                grad_norm_ema: 0.0,
899            }
900        }
901    }
902
903    /// Deterministic pseudo-random sequence in [0, 1) (LCG), used so tests stay
904    /// reproducible without pulling in an RNG dependency.
905    struct Lcg {
906        state: u64,
907    }
908    impl Lcg {
909        fn new(seed: u64) -> Self {
910            Self { state: seed }
911        }
912        fn next_unit(&mut self) -> f64 {
913            // Numerical Recipes LCG constants.
914            self.state = self
915                .state
916                .wrapping_mul(6364136223846793005)
917                .wrapping_add(1442695040888963407);
918            // Use the top 53 bits for a double in [0, 1).
919            ((self.state >> 11) as f64) / ((1u64 << 53) as f64)
920        }
921        /// Centered noise in [-amp, amp].
922        fn next_noise(&mut self, amp: f64) -> f64 {
923            (self.next_unit() * 2.0 - 1.0) * amp
924        }
925    }
926
927    #[test]
928    fn test_ewma_mean_converges_to_stream_mean() {
929        let mut detector = DriftDetector::new(detector_config()).expect("valid config");
930        let target = 5.0;
931        let mut rng = Lcg::new(42);
932        // Warm-up on the target plus tiny noise, then keep feeding the same.
933        for _ in 0..200 {
934            let x = target + rng.next_noise(0.01);
935            let _ = detector.update(x);
936        }
937        assert!(detector.is_warmed_up());
938        assert!(
939            (detector.ewma_mean() - target).abs() < 0.05,
940            "EWMA mean {} should be close to {}",
941            detector.ewma_mean(),
942            target
943        );
944        assert!(
945            (detector.reference_mean() - target).abs() < 0.05,
946            "reference mean {} should be close to {}",
947            detector.reference_mean(),
948            target
949        );
950    }
951
952    #[test]
953    fn test_cusum_stable_on_stationary_stream() {
954        let mut detector = DriftDetector::new(detector_config()).expect("valid config");
955        let mut rng = Lcg::new(7);
956        let mut drift_count = 0usize;
957        for _ in 0..500 {
958            let x = 2.0 + rng.next_noise(0.05);
959            if detector.update(x) != DriftSignal::Stable {
960                drift_count += 1;
961            }
962        }
963        // A correctly-calibrated `control_limit = 3.0` (three-sigma) EWMA chart
964        // (see the F79 fix: the control limit must include the steady-state
965        // factor sqrt(lambda / (2 - lambda))) has an intrinsic two-sided
966        // false-alarm rate of ~0.27% per observation, so on ~480 post-warmup
967        // stationary draws an occasional alarm (expected count ~1.3) is normal
968        // statistical behaviour, not drift. Before that fix the chart's control
969        // limit was scaled to the *raw* signal's standard deviation instead of
970        // the smoothed statistic's, making it ~3x too wide and this count
971        // spuriously always 0 regardless of `control_limit`. Bound the count
972        // generously instead of requiring exactly zero.
973        assert!(
974            drift_count <= 5,
975            "stationary stream raised {drift_count} drift signals out of 500, \
976             far more than the ~1.3 expected from a three-sigma chart"
977        );
978    }
979
980    /// F79 regression: the EWMA chart's control limit must be scaled by the
981    /// steady-state factor `sqrt(lambda / (2 - lambda))`, which accounts for
982    /// the smoothed statistic (`ewma_mean`) having strictly less variance than
983    /// the raw signal whose variance `ewma_var` actually tracks. Without the
984    /// factor, the limit is scaled to the raw signal's (much larger) standard
985    /// deviation, under-reacting by `sqrt((2-lambda)/lambda)` (~3x at the
986    /// default lambda = 0.2).
987    #[test]
988    fn test_ewma_control_limit_includes_steady_state_factor() {
989        let cfg = detector_config();
990        let mut detector = DriftDetector::new(cfg).expect("valid config");
991        let mut rng = Lcg::new(99);
992        for _ in 0..cfg.warmup_steps {
993            let _ = detector.update(rng.next_noise(0.01));
994        }
995        assert!(detector.is_warmed_up());
996        // Let ewma_var settle on a few more near-noiseless observations at the
997        // reference mean.
998        for _ in 0..5 {
999            let _ = detector.update(0.0);
1000        }
1001
1002        // Independently recompute both the buggy (pre-F79) and the corrected
1003        // control limit from the detector's own internal EWMA variance.
1004        let ewma_std = detector.ewma_var.sqrt().max(cfg.min_std);
1005        let buggy_limit = cfg.control_limit * ewma_std;
1006        let correct_factor = (cfg.lambda / (2.0 - cfg.lambda)).sqrt();
1007        let correct_limit = buggy_limit * correct_factor;
1008        assert!(
1009            correct_limit < buggy_limit,
1010            "the steady-state factor must strictly tighten the limit"
1011        );
1012
1013        // A constant shift comfortably inside the buggy limit (so `ewma_mean`,
1014        // which converges asymptotically *toward* `shift` and never exceeds
1015        // it, could not have breached the old, unscaled formula no matter how
1016        // many observations were fed) but well above the corrected limit.
1017        let shift = buggy_limit * 0.9;
1018        assert!(shift < buggy_limit && shift > correct_limit);
1019
1020        let mut breached = false;
1021        for _ in 0..30 {
1022            if detector.update(shift) == DriftSignal::UpwardDrift {
1023                breached = true;
1024                break;
1025            }
1026        }
1027        assert!(
1028            breached,
1029            "a sustained shift of {shift}, comfortably below the pre-F79 control \
1030             limit ({buggy_limit}) but above the corrected one ({correct_limit}), \
1031             must eventually be flagged by the EWMA chart now that the \
1032             steady-state factor is applied"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_cusum_detects_upward_shift() {
1038        let mut detector = DriftDetector::new(detector_config()).expect("valid config");
1039        let mut rng = Lcg::new(11);
1040        // Warm-up around 0.
1041        for _ in 0..30 {
1042            let _ = detector.update(rng.next_noise(0.05));
1043        }
1044        assert!(detector.is_warmed_up());
1045        // Inject a large positive mean shift; a clear upward drift must fire.
1046        let mut fired_up = false;
1047        for _ in 0..50 {
1048            let x = 3.0 + rng.next_noise(0.05);
1049            if detector.update(x) == DriftSignal::UpwardDrift {
1050                fired_up = true;
1051                break;
1052            }
1053        }
1054        assert!(fired_up, "upward mean shift must raise UpwardDrift");
1055    }
1056
1057    #[test]
1058    fn test_cusum_detects_downward_shift() {
1059        let mut detector = DriftDetector::new(detector_config()).expect("valid config");
1060        let mut rng = Lcg::new(13);
1061        for _ in 0..30 {
1062            let _ = detector.update(rng.next_noise(0.05));
1063        }
1064        let mut fired_down = false;
1065        for _ in 0..50 {
1066            let x = -3.0 + rng.next_noise(0.05);
1067            if detector.update(x) == DriftSignal::DownwardDrift {
1068                fired_down = true;
1069                break;
1070            }
1071        }
1072        assert!(fired_down, "downward mean shift must raise DownwardDrift");
1073    }
1074
1075    #[test]
1076    fn test_detector_warmup_reports_stable() {
1077        let mut detector = DriftDetector::new(detector_config()).expect("valid config");
1078        // Even with wild values, warm-up never reports drift.
1079        for i in 0..20 {
1080            let x = if i % 2 == 0 { 100.0 } else { -100.0 };
1081            assert_eq!(detector.update(x), DriftSignal::Stable);
1082        }
1083        assert!(detector.is_warmed_up());
1084    }
1085
1086    #[test]
1087    fn test_controller_decreases_lr_on_diverging_loss() {
1088        let config = RealtimeAdaptationConfig::<f64>::default();
1089        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1090        let initial_lr = controller.learning_rate();
1091        let mut rng = Lcg::new(101);
1092        // Stable warm-up phase for both streams.
1093        for _ in 0..30 {
1094            let _ = controller.observe(1.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1095        }
1096        // Now drive the loss sharply upward (diverging).
1097        let mut loss = 1.0;
1098        for _ in 0..60 {
1099            loss += 0.5;
1100            let _ = controller.observe(loss, 0.5 + rng.next_noise(0.01));
1101        }
1102        assert!(
1103            controller.learning_rate() < initial_lr,
1104            "lr {} should drop below initial {} on diverging loss",
1105            controller.learning_rate(),
1106            initial_lr
1107        );
1108    }
1109
1110    #[test]
1111    fn test_controller_decreases_lr_on_exploding_grad() {
1112        let config = RealtimeAdaptationConfig::<f64>::default();
1113        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1114        let initial_lr = controller.learning_rate();
1115        let mut rng = Lcg::new(202);
1116        for _ in 0..30 {
1117            let _ = controller.observe(1.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1118        }
1119        // Explode the gradient norm while keeping loss roughly flat.
1120        let mut g = 0.5;
1121        for _ in 0..60 {
1122            g += 0.3;
1123            let _ = controller.observe(1.0 + rng.next_noise(0.01), g);
1124        }
1125        assert!(
1126            controller.learning_rate() < initial_lr,
1127            "lr {} should drop below initial {} on exploding grad norm",
1128            controller.learning_rate(),
1129            initial_lr
1130        );
1131    }
1132
1133    #[test]
1134    fn test_controller_increases_lr_on_improving_loss() {
1135        let config = RealtimeAdaptationConfig::<f64>::default();
1136        let lr_max = config.lr_max;
1137        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1138        let initial_lr = controller.learning_rate();
1139        let mut rng = Lcg::new(303);
1140        // Warm-up around a high loss.
1141        for _ in 0..30 {
1142            let _ = controller.observe(10.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1143        }
1144        // Steadily decreasing loss with a small, bounded gradient norm.
1145        let mut loss = 10.0;
1146        for _ in 0..200 {
1147            loss -= 0.04;
1148            let _ = controller.observe(loss, 0.5 + rng.next_noise(0.01));
1149        }
1150        assert!(
1151            controller.learning_rate() > initial_lr,
1152            "lr {} should rise above initial {} on improving loss",
1153            controller.learning_rate(),
1154            initial_lr
1155        );
1156        assert!(
1157            controller.learning_rate() <= lr_max + 1e-15,
1158            "lr {} must never exceed lr_max {}",
1159            controller.learning_rate(),
1160            lr_max
1161        );
1162    }
1163
1164    #[test]
1165    fn test_controller_plateau_triggers_reduction() {
1166        // Disable the drift paths' interference by using a flat loss: the
1167        // plateau tracker (not divergence/improvement) must drive the cut.
1168        let config = RealtimeAdaptationConfig::<f64> {
1169            plateau_patience: 8,
1170            cooldown_steps: 0,
1171            ..Default::default()
1172        };
1173        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1174        let initial_lr = controller.learning_rate();
1175        // Warm up, then a long perfectly-flat loss plateau.
1176        for _ in 0..25 {
1177            let _ = controller.observe(3.0, 0.5);
1178        }
1179        let lr_before = controller.learning_rate();
1180        for _ in 0..30 {
1181            let _ = controller.observe(3.0, 0.5);
1182        }
1183        assert!(
1184            controller.learning_rate() < lr_before,
1185            "flat loss plateau should reduce lr: before={}, after={}",
1186            lr_before,
1187            controller.learning_rate()
1188        );
1189        assert!(controller.learning_rate() <= initial_lr);
1190    }
1191
1192    #[test]
1193    fn test_lr_and_momentum_stay_within_clamps() {
1194        let config = RealtimeAdaptationConfig::<f64>::default();
1195        let lr_min = config.lr_min;
1196        let lr_max = config.lr_max;
1197        let mom_min = config.mom_min;
1198        let mom_max = config.mom_max;
1199        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1200        let mut rng = Lcg::new(404);
1201        for i in 0..1000 {
1202            // A messy but deterministic stream mixing trends, spikes, and noise.
1203            let phase = (i / 50) % 4;
1204            let base = match phase {
1205                0 => 5.0 - 0.05 * (i % 50) as f64, // improving
1206                1 => 1.0 + 0.1 * (i % 50) as f64,  // diverging
1207                2 => 2.0,                          // plateau
1208                _ => 3.0 + rng.next_noise(1.0),    // noisy
1209            };
1210            let loss = base + rng.next_noise(0.05);
1211            let grad = (0.5 + rng.next_noise(0.4)).abs() + 0.01;
1212            let decision = controller.observe(loss, grad);
1213            assert!(
1214                decision.learning_rate >= lr_min - 1e-15
1215                    && decision.learning_rate <= lr_max + 1e-15,
1216                "lr {} out of clamp [{}, {}] at step {}",
1217                decision.learning_rate,
1218                lr_min,
1219                lr_max,
1220                i
1221            );
1222            assert!(
1223                decision.momentum >= mom_min - 1e-15 && decision.momentum <= mom_max + 1e-15,
1224                "momentum {} out of clamp [{}, {}] at step {}",
1225                decision.momentum,
1226                mom_min,
1227                mom_max,
1228                i
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn test_cooldown_suppresses_immediate_second_adjustment() {
1235        let config = RealtimeAdaptationConfig::<f64> {
1236            cooldown_steps: 5,
1237            ..Default::default()
1238        };
1239        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1240        let mut rng = Lcg::new(505);
1241        for _ in 0..30 {
1242            let _ = controller.observe(1.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1243        }
1244        // Force a divergence-driven adjustment.
1245        let mut loss = 1.0;
1246        let mut adjusted_step = None;
1247        for step in 0..100 {
1248            loss += 0.5;
1249            let d = controller.observe(loss, 0.5 + rng.next_noise(0.01));
1250            if d.reason != AdaptationReason::NoChange {
1251                adjusted_step = Some(step);
1252                break;
1253            }
1254        }
1255        let adjusted_step = adjusted_step.expect("an adjustment should have occurred");
1256        let lr_after_adjust = controller.learning_rate();
1257        // The immediately following observations (still diverging) must not
1258        // change the lr again while cooldown is active.
1259        for _ in 0..5 {
1260            loss += 0.5;
1261            let d = controller.observe(loss, 0.5);
1262            assert_eq!(
1263                d.reason,
1264                AdaptationReason::NoChange,
1265                "cooldown must suppress adjustment right after step {adjusted_step}"
1266            );
1267            assert!(
1268                (d.learning_rate - lr_after_adjust).abs() < 1e-18,
1269                "lr must be unchanged during cooldown"
1270            );
1271        }
1272    }
1273
1274    /// Build a default controller config, apply a mutation, and return it.
1275    /// Taking the base as a function argument keeps this out of the
1276    /// `field_reassign_with_default` lint's pattern (a `let` binding followed by
1277    /// field assignments) while still letting each test tweak one field.
1278    fn mutated_config<F>(mut f: F) -> RealtimeAdaptationConfig<f64>
1279    where
1280        F: FnMut(&mut RealtimeAdaptationConfig<f64>),
1281    {
1282        let mut config = RealtimeAdaptationConfig::<f64>::default();
1283        f(&mut config);
1284        config
1285    }
1286
1287    #[test]
1288    fn test_config_validate_rejects_bad_lambda() {
1289        let low = mutated_config(|c| c.loss_detector.lambda = 0.0);
1290        assert!(low.validate().is_err());
1291        let high = mutated_config(|c| c.loss_detector.lambda = 1.5);
1292        assert!(high.validate().is_err());
1293    }
1294
1295    #[test]
1296    fn test_config_validate_rejects_bad_cusum_params() {
1297        let bad_threshold = mutated_config(|c| c.grad_detector.cusum_threshold = 0.0);
1298        assert!(bad_threshold.validate().is_err());
1299        let bad_slack = mutated_config(|c| c.grad_detector.cusum_slack = -1.0);
1300        assert!(bad_slack.validate().is_err());
1301    }
1302
1303    #[test]
1304    fn test_config_validate_rejects_lr_bounds() {
1305        let config = mutated_config(|c| {
1306            c.lr_min = 1.0;
1307            c.lr_max = 0.5;
1308        });
1309        assert!(config.validate().is_err());
1310    }
1311
1312    #[test]
1313    fn test_config_validate_rejects_momentum_bounds() {
1314        let config = mutated_config(|c| {
1315            c.mom_min = 0.95;
1316            c.mom_max = 0.5;
1317        });
1318        assert!(config.validate().is_err());
1319    }
1320
1321    #[test]
1322    fn test_config_validate_rejects_zero_patience() {
1323        let config = mutated_config(|c| c.plateau_patience = 0);
1324        assert!(config.validate().is_err());
1325    }
1326
1327    #[test]
1328    fn test_config_validate_rejects_bad_growth_decay() {
1329        let bad_decay = mutated_config(|c| c.lr_decay = 1.0); // must be < 1
1330        assert!(bad_decay.validate().is_err());
1331        let bad_growth = mutated_config(|c| c.lr_growth = 0.9); // must be > 1
1332        assert!(bad_growth.validate().is_err());
1333    }
1334
1335    #[test]
1336    fn test_default_config_validates_ok() {
1337        let config = RealtimeAdaptationConfig::<f64>::default();
1338        assert!(config.validate().is_ok());
1339        let detector_default = DriftDetectorConfig::<f64>::default();
1340        assert!(detector_default.validate().is_ok());
1341    }
1342
1343    #[test]
1344    fn test_apply_to_pushes_learning_rate() {
1345        let config = RealtimeAdaptationConfig::<f64>::default();
1346        let initial_lr = config.initial_lr;
1347        let controller = RealtimeAdaptationController::new(config).expect("valid config");
1348        let mut opt = DummyOptimizer { lr: 999.0 };
1349        controller.apply_to(&mut opt);
1350        assert!(
1351            (opt.get_learning_rate() - initial_lr).abs() < 1e-15,
1352            "apply_to should push the controller lr {} onto the optimizer (got {})",
1353            initial_lr,
1354            opt.get_learning_rate()
1355        );
1356    }
1357
1358    #[test]
1359    fn test_reset_restores_initial_state() {
1360        let config = RealtimeAdaptationConfig::<f64>::default();
1361        let initial_lr = config.initial_lr;
1362        let initial_mom = config.initial_momentum;
1363        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1364        let mut rng = Lcg::new(606);
1365        for _ in 0..50 {
1366            let _ = controller.observe(1.0 + rng.next_noise(0.5), 0.5 + rng.next_noise(0.5).abs());
1367        }
1368        controller.reset();
1369        assert_eq!(controller.step_count(), 0);
1370        assert!((controller.learning_rate() - initial_lr).abs() < 1e-15);
1371        assert!((controller.momentum() - initial_mom).abs() < 1e-15);
1372        assert!(!controller.loss_detector().is_warmed_up());
1373        assert!(!controller.grad_detector().is_warmed_up());
1374    }
1375
1376    #[test]
1377    fn test_drift_detected_flag_set_on_shift() {
1378        let config = RealtimeAdaptationConfig::<f64>::default();
1379        let mut controller = RealtimeAdaptationController::new(config).expect("valid config");
1380        let mut rng = Lcg::new(707);
1381        for _ in 0..30 {
1382            let _ = controller.observe(1.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1383        }
1384        let mut saw_drift = false;
1385        let mut loss = 1.0;
1386        for _ in 0..60 {
1387            loss += 0.5;
1388            if controller
1389                .observe(loss, 0.5 + rng.next_noise(0.01))
1390                .drift_detected
1391            {
1392                saw_drift = true;
1393                break;
1394            }
1395        }
1396        assert!(saw_drift, "drift_detected should be set during divergence");
1397    }
1398}