1use std::fmt::Debug;
54
55use scirs2_core::numeric::Float;
56
57use crate::domain_optimizers::AdvancedOptimizer;
58use crate::error::{OptimError, Result};
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum DriftSignal {
63 Stable,
65 UpwardDrift,
67 DownwardDrift,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct DriftDetectorConfig<T>
74where
75 T: Float + Debug + Send + Sync + 'static,
76{
77 pub lambda: T,
80 pub control_limit: T,
82 pub cusum_slack: T,
84 pub cusum_threshold: T,
86 pub warmup_steps: usize,
89 pub min_std: T,
92}
93
94impl<T> DriftDetectorConfig<T>
95where
96 T: Float + Debug + Send + Sync + 'static,
97{
98 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#[derive(Debug, Clone)]
166pub struct DriftDetector<T>
167where
168 T: Float + Debug + Send + Sync + 'static,
169{
170 config: DriftDetectorConfig<T>,
172 count: usize,
174 warmup_sum: T,
176 warmup_sum_sq: T,
178 reference_mean: T,
180 reference_std: T,
182 warmed_up: bool,
184 ewma_mean: T,
186 ewma_var: T,
188 cusum_pos: T,
190 cusum_neg: T,
192}
193
194impl<T> DriftDetector<T>
195where
196 T: Float + Debug + Send + Sync + 'static,
197{
198 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 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 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 self.ewma_mean = mean;
245 self.ewma_var = self.reference_std * self.reference_std;
246 self.warmed_up = true;
247 }
248
249 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 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 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 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 self.cusum_pos = (self.cusum_pos + (centered - k)).max(T::zero());
304 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 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 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 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 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 pub fn is_warmed_up(&self) -> bool {
360 self.warmed_up
361 }
362
363 pub fn ewma_mean(&self) -> T {
365 self.ewma_mean
366 }
367
368 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 pub fn reference_mean(&self) -> T {
380 self.reference_mean
381 }
382
383 pub fn reference_std(&self) -> T {
385 self.reference_std
386 }
387
388 pub fn cusum_pos(&self) -> T {
390 self.cusum_pos
391 }
392
393 pub fn cusum_neg(&self) -> T {
395 self.cusum_neg
396 }
397
398 pub fn count(&self) -> usize {
400 self.count
401 }
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum AdaptationReason {
407 NoChange,
409 LossDivergence,
411 GradientExplosion,
413 Plateau,
415 StableImprovement,
417}
418
419impl AdaptationReason {
420 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#[derive(Debug, Clone)]
434pub struct AdaptationDecision<T>
435where
436 T: Float + Debug + Send + Sync + 'static,
437{
438 pub learning_rate: T,
440 pub momentum: T,
442 pub drift_detected: bool,
444 pub reason: AdaptationReason,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq)]
450pub struct RealtimeAdaptationConfig<T>
451where
452 T: Float + Debug + Send + Sync + 'static,
453{
454 pub loss_detector: DriftDetectorConfig<T>,
456 pub grad_detector: DriftDetectorConfig<T>,
458 pub initial_lr: T,
460 pub lr_min: T,
462 pub lr_max: T,
464 pub lr_decay: T,
466 pub lr_growth: T,
468 pub initial_momentum: T,
470 pub mom_min: T,
472 pub mom_max: T,
474 pub mom_damp: T,
476 pub plateau_patience: usize,
479 pub plateau_tol: T,
482 pub cooldown_steps: usize,
485 pub grad_norm_ceiling_factor: T,
488}
489
490impl<T> RealtimeAdaptationConfig<T>
491where
492 T: Float + Debug + Send + Sync + 'static,
493{
494 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#[derive(Debug, Clone)]
608pub struct RealtimeAdaptationController<T>
609where
610 T: Float + Debug + Send + Sync + 'static,
611{
612 config: RealtimeAdaptationConfig<T>,
614 loss_detector: DriftDetector<T>,
616 grad_detector: DriftDetector<T>,
618 learning_rate: T,
620 momentum: T,
622 cooldown_remaining: usize,
624 plateau_loss_ema: T,
626 plateau_initialised: bool,
628 plateau_counter: usize,
630 step_count: usize,
632}
633
634impl<T> RealtimeAdaptationController<T>
635where
636 T: Float + Debug + Send + Sync + 'static,
637{
638 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 pub fn learning_rate(&self) -> T {
666 self.learning_rate
667 }
668
669 pub fn momentum(&self) -> T {
671 self.momentum
672 }
673
674 pub fn step_count(&self) -> usize {
676 self.step_count
677 }
678
679 pub fn cooldown_remaining(&self) -> usize {
681 self.cooldown_remaining
682 }
683
684 pub fn loss_detector(&self) -> &DriftDetector<T> {
686 &self.loss_detector
687 }
688
689 pub fn grad_detector(&self) -> &DriftDetector<T> {
691 &self.grad_detector
692 }
693
694 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 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 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 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 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 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 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 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 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 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 self.learning_rate = self.clamp_lr(self.learning_rate * self.config.lr_decay);
812 self.plateau_counter = 0;
815 AdaptationReason::Plateau
816 } else if loss_improving && self.grad_norm_bounded() {
817 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 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 pub fn apply_to(&self, optimizer: &mut dyn AdvancedOptimizer<T>) {
842 optimizer.set_learning_rate(self.learning_rate);
843 }
844
845 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 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 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 self.state = self
915 .state
916 .wrapping_mul(6364136223846793005)
917 .wrapping_add(1442695040888963407);
918 ((self.state >> 11) as f64) / ((1u64 << 53) as f64)
920 }
921 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 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 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 #[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 for _ in 0..5 {
999 let _ = detector.update(0.0);
1000 }
1001
1002 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 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 for _ in 0..30 {
1042 let _ = detector.update(rng.next_noise(0.05));
1043 }
1044 assert!(detector.is_warmed_up());
1045 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 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 for _ in 0..30 {
1094 let _ = controller.observe(1.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1095 }
1096 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 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 for _ in 0..30 {
1142 let _ = controller.observe(10.0 + rng.next_noise(0.01), 0.5 + rng.next_noise(0.01));
1143 }
1144 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 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 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 let phase = (i / 50) % 4;
1204 let base = match phase {
1205 0 => 5.0 - 0.05 * (i % 50) as f64, 1 => 1.0 + 0.1 * (i % 50) as f64, 2 => 2.0, _ => 3.0 + rng.next_noise(1.0), };
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 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 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 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); assert!(bad_decay.validate().is_err());
1331 let bad_growth = mutated_config(|c| c.lr_growth = 0.9); 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}