Skip to main content

optirs_core/streaming/enhanced_adaptive_lr/
signals.rs

1// Real adaptation signals for the enhanced adaptive learning-rate controller
2// (findings E1, E3, E4, E5, E6).
3//
4// The block this file replaces was introduced in the source as "Implementation
5// stubs for the various components / In a full implementation, these would
6// contain sophisticated algorithms":
7//
8// * E1/E5 — `resolve_signals` multiplied the hardcoded literal `0.001` by the
9//   vote instead of the live learning rate, and never read `signal_weights`,
10//   `signal_reliability`, `conflict_resolution`, `voting_history` or
11//   `last_decision`. Four of the five conflict-resolution strategies were
12//   unreachable.
13// * E3 — `DriftAwareAdapter::generate_signal` returned a hardcoded "No drift
14//   detected" vote because `drift_detectors` was `vec![]` and the local
15//   detector type had no methods.
16// * E4 — `memory_pressure` was never written, so the resource signal always
17//   read `0.0`, interpreted it as spare capacity and pushed the learning rate
18//   up on every single step.
19// * E6 — every sub-adapter constructor took the configuration and ignored it.
20
21use super::*;
22
23/// Bound on the retained signal-vote log.
24const MAX_VOTING_HISTORY: usize = 512;
25
26/// Bound on the retained per-metric history.
27const MAX_METRIC_HISTORY: usize = 512;
28
29fn to_scalar<A: Float>(value: f64) -> A {
30    A::from(value).unwrap_or_else(A::zero)
31}
32
33fn from_scalar<A: Float>(value: A) -> f64 {
34    value.to_f64().unwrap_or(0.0)
35}
36
37fn mean(values: &[f64]) -> f64 {
38    if values.is_empty() {
39        0.0
40    } else {
41        values.iter().sum::<f64>() / values.len() as f64
42    }
43}
44
45fn variance(values: &[f64]) -> f64 {
46    if values.len() < 2 {
47        return 0.0;
48    }
49    let m = mean(values);
50    values.iter().map(|value| (value - m).powi(2)).sum::<f64>() / values.len() as f64
51}
52
53/// Ordinary-least-squares slope against the sample index.
54fn slope(values: &[f64]) -> f64 {
55    if values.len() < 2 {
56        return 0.0;
57    }
58    let x_mean = (values.len() - 1) as f64 / 2.0;
59    let y_mean = mean(values);
60    let mut numerator = 0.0;
61    let mut denominator = 0.0;
62    for (index, value) in values.iter().enumerate() {
63        let dx = index as f64 - x_mean;
64        numerator += dx * (value - y_mean);
65        denominator += dx * dx;
66    }
67    if denominator == 0.0 {
68        0.0
69    } else {
70        numerator / denominator
71    }
72}
73
74// ---------------------------------------------------------------------------
75// E1/E5: multi-signal resolution
76// ---------------------------------------------------------------------------
77
78impl<A: Float + Default + Clone + Send + Sync> MultiSignalAdaptationStrategy<A> {
79    pub(crate) fn new(config: &AdaptiveLRConfig<A>) -> Result<Self> {
80        // E6: the configuration is actually read now. Signals the operator
81        // disabled get zero weight, so they cannot influence a decision even if
82        // something else pushes a vote for them.
83        let mut signal_weights = HashMap::new();
84        let enabled_weight = A::one();
85        let disabled_weight = A::zero();
86        signal_weights.insert(
87            AdaptationSignalType::GradientMagnitude,
88            if config.enable_gradient_adaptation {
89                enabled_weight
90            } else {
91                disabled_weight
92            },
93        );
94        signal_weights.insert(
95            AdaptationSignalType::GradientVariance,
96            if config.enable_gradient_adaptation {
97                enabled_weight
98            } else {
99                disabled_weight
100            },
101        );
102        signal_weights.insert(
103            AdaptationSignalType::LossProgression,
104            if config.enable_performance_adaptation {
105                enabled_weight
106            } else {
107                disabled_weight
108            },
109        );
110        signal_weights.insert(
111            AdaptationSignalType::AccuracyTrend,
112            if config.enable_performance_adaptation {
113                enabled_weight
114            } else {
115                disabled_weight
116            },
117        );
118        signal_weights.insert(
119            AdaptationSignalType::ConceptDrift,
120            if config.enable_drift_adaptation {
121                enabled_weight
122            } else {
123                disabled_weight
124            },
125        );
126        signal_weights.insert(
127            AdaptationSignalType::ResourceUtilization,
128            if config.enable_resource_adaptation {
129                enabled_weight
130            } else {
131                disabled_weight
132            },
133        );
134        signal_weights.insert(AdaptationSignalType::ModelComplexity, enabled_weight);
135        signal_weights.insert(AdaptationSignalType::DataQuality, enabled_weight);
136
137        Ok(Self {
138            signal_weights,
139            voting_history: VecDeque::with_capacity(MAX_VOTING_HISTORY),
140            conflict_resolution: if config.use_ensemble_voting {
141                ConflictResolution::WeightedAverage
142            } else {
143                ConflictResolution::HighestConfidence
144            },
145            signal_reliability: HashMap::new(),
146            last_decision: None,
147        })
148    }
149
150    /// Effective weight of a signal: its configured weight times its measured
151    /// reliability (seeded neutrally at 1.0 until effectiveness is observed).
152    fn effective_weight(&self, signal_type: AdaptationSignalType) -> f64 {
153        let configured = self
154            .signal_weights
155            .get(&signal_type)
156            .map(|weight| from_scalar(*weight))
157            .unwrap_or(1.0);
158        let reliability = self
159            .signal_reliability
160            .get(&signal_type)
161            .map(|value| from_scalar(*value))
162            .unwrap_or(1.0);
163        (configured * reliability).max(0.0)
164    }
165
166    pub(crate) fn resolve_signals(
167        &mut self,
168        signals: Vec<SignalVote<A>>,
169        current_lr: A,
170        sensitivity: A,
171        use_ensemble_voting: bool,
172        _step: usize,
173    ) -> Result<AdaptationDecision<A>> {
174        // Record every vote, bounded.
175        for signal in &signals {
176            self.voting_history.push_back(signal.clone());
177        }
178        while self.voting_history.len() > MAX_VOTING_HISTORY {
179            self.voting_history.pop_front();
180        }
181
182        if signals.is_empty() {
183            let decision = AdaptationDecision {
184                // With no signal there is no reason to change anything; the old
185                // code snapped the learning rate to a hardcoded 0.001 here.
186                new_lr: current_lr,
187                lr_multiplier: A::one(),
188                contributing_signals: vec![],
189                confidence: A::zero(),
190                rationale: "No signals available; learning rate left unchanged".to_string(),
191                timestamp: Instant::now(),
192            };
193            self.last_decision = Some(decision.clone());
194            return Ok(decision);
195        }
196
197        self.conflict_resolution = if use_ensemble_voting {
198            self.conflict_resolution
199        } else {
200            ConflictResolution::HighestConfidence
201        };
202
203        // Weighted candidates: (multiplier, weight, confidence, signal type).
204        let candidates: Vec<(f64, f64, f64, AdaptationSignalType)> = signals
205            .iter()
206            .map(|signal| {
207                (
208                    from_scalar(signal.recommended_lr_change),
209                    self.effective_weight(signal.signal_type) * from_scalar(signal.confidence),
210                    from_scalar(signal.confidence),
211                    signal.signal_type,
212                )
213            })
214            .filter(|(multiplier, weight, _, _)| multiplier.is_finite() && *weight > 0.0)
215            .collect();
216
217        if candidates.is_empty() {
218            let decision = AdaptationDecision {
219                new_lr: current_lr,
220                lr_multiplier: A::one(),
221                contributing_signals: signals.iter().map(|s| s.signal_type).collect(),
222                confidence: A::zero(),
223                rationale: "All adaptation signals are disabled or unreliable".to_string(),
224                timestamp: Instant::now(),
225            };
226            self.last_decision = Some(decision.clone());
227            return Ok(decision);
228        }
229
230        // E1/E5: all five conflict-resolution strategies are reachable now.
231        let (raw_multiplier, rationale) = match self.conflict_resolution {
232            ConflictResolution::WeightedAverage => {
233                let total_weight: f64 = candidates.iter().map(|(_, weight, _, _)| weight).sum();
234                let weighted: f64 = candidates
235                    .iter()
236                    .map(|(multiplier, weight, _, _)| multiplier * weight)
237                    .sum();
238                (
239                    weighted / total_weight,
240                    "Reliability-weighted average of adaptation signals".to_string(),
241                )
242            }
243            ConflictResolution::HighestConfidence => {
244                let best = candidates
245                    .iter()
246                    .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))
247                    .copied()
248                    .unwrap_or((1.0, 1.0, 0.0, AdaptationSignalType::LossProgression));
249                (best.0, format!("Highest-confidence signal ({:?})", best.3))
250            }
251            ConflictResolution::MajorityVote { threshold } => {
252                // Group votes by direction; a direction needs `threshold` of the
253                // weight to win, otherwise nothing changes.
254                let total_weight: f64 = candidates.iter().map(|(_, weight, _, _)| weight).sum();
255                let up: f64 = candidates
256                    .iter()
257                    .filter(|(multiplier, _, _, _)| *multiplier > 1.0)
258                    .map(|(_, weight, _, _)| weight)
259                    .sum();
260                let down: f64 = candidates
261                    .iter()
262                    .filter(|(multiplier, _, _, _)| *multiplier < 1.0)
263                    .map(|(_, weight, _, _)| weight)
264                    .sum();
265                let share = |value: f64| {
266                    if total_weight > 0.0 {
267                        value / total_weight
268                    } else {
269                        0.0
270                    }
271                };
272                if share(up) >= threshold {
273                    let mean_up = mean(
274                        &candidates
275                            .iter()
276                            .filter(|(multiplier, _, _, _)| *multiplier > 1.0)
277                            .map(|(multiplier, _, _, _)| *multiplier)
278                            .collect::<Vec<f64>>(),
279                    );
280                    (mean_up, "Majority voted to increase".to_string())
281                } else if share(down) >= threshold {
282                    let mean_down = mean(
283                        &candidates
284                            .iter()
285                            .filter(|(multiplier, _, _, _)| *multiplier < 1.0)
286                            .map(|(multiplier, _, _, _)| *multiplier)
287                            .collect::<Vec<f64>>(),
288                    );
289                    (mean_down, "Majority voted to decrease".to_string())
290                } else {
291                    (
292                        1.0,
293                        "No direction reached the majority threshold".to_string(),
294                    )
295                }
296            }
297            ConflictResolution::Conservative => {
298                // The smallest change any signal asked for.
299                let smallest = candidates
300                    .iter()
301                    .min_by(|a, b| {
302                        (a.0 - 1.0)
303                            .abs()
304                            .partial_cmp(&(b.0 - 1.0).abs())
305                            .unwrap_or(std::cmp::Ordering::Equal)
306                    })
307                    .map(|(multiplier, _, _, _)| *multiplier)
308                    .unwrap_or(1.0);
309                (
310                    smallest,
311                    "Conservative resolution: smallest requested change".to_string(),
312                )
313            }
314            ConflictResolution::MetaLearned => {
315                // Weight each signal by its *measured* reliability alone, which
316                // is what the meta-learned resolution means here: trust the
317                // signals that have historically produced improvements.
318                let mut total = 0.0;
319                let mut weighted = 0.0;
320                for (multiplier, _, _, signal_type) in &candidates {
321                    let reliability = self
322                        .signal_reliability
323                        .get(signal_type)
324                        .map(|value| from_scalar(*value))
325                        .unwrap_or(0.0);
326                    total += reliability;
327                    weighted += multiplier * reliability;
328                }
329                if total > 0.0 {
330                    (
331                        weighted / total,
332                        "Meta-learned resolution from measured signal reliability".to_string(),
333                    )
334                } else {
335                    // Nothing has been measured yet; say so instead of guessing.
336                    (
337                        1.0,
338                        "Meta-learned resolution has no measured reliability yet".to_string(),
339                    )
340                }
341            }
342        };
343
344        // `adaptation_sensitivity` scales how far a single decision may move the
345        // learning rate (it used to be ignored entirely).
346        let sensitivity = from_scalar(sensitivity).abs().clamp(0.0, 1.0);
347        let scale = if sensitivity > 0.0 { sensitivity } else { 1.0 };
348        let multiplier = 1.0 + (raw_multiplier - 1.0) * scale;
349        let multiplier = if multiplier.is_finite() && multiplier > 0.0 {
350            multiplier
351        } else {
352            1.0
353        };
354
355        let confidence = mean(
356            &candidates
357                .iter()
358                .map(|(_, _, confidence, _)| *confidence)
359                .collect::<Vec<f64>>(),
360        );
361
362        let decision = AdaptationDecision {
363            // The live learning rate is the base, so adaptation composes across
364            // steps instead of resetting.
365            new_lr: current_lr * to_scalar(multiplier),
366            lr_multiplier: to_scalar(multiplier),
367            contributing_signals: candidates
368                .iter()
369                .map(|(_, _, _, signal_type)| *signal_type)
370                .collect(),
371            confidence: to_scalar(confidence),
372            rationale,
373            timestamp: Instant::now(),
374        };
375        self.last_decision = Some(decision.clone());
376        Ok(decision)
377    }
378
379    pub(crate) fn update_signal_reliability(
380        &mut self,
381        signal_type: AdaptationSignalType,
382        effectiveness: A,
383    ) {
384        let reliability = self
385            .signal_reliability
386            .entry(signal_type)
387            .or_insert_with(|| A::from(0.5).unwrap_or_else(A::zero));
388
389        // Update reliability using exponential moving average, clamped to a
390        // non-negative weight so a bad run cannot invert a signal's vote.
391        let alpha = A::from(0.1).unwrap_or_else(A::zero);
392        let updated = (*reliability) * (A::one() - alpha) + effectiveness * alpha;
393        *reliability = updated.max(A::zero());
394    }
395}
396
397// ---------------------------------------------------------------------------
398// gradient-based signal
399// ---------------------------------------------------------------------------
400
401impl<A: Float + Default + Clone + Send + Sync> GradientBasedAdapter<A> {
402    pub(crate) fn new(config: &AdaptiveLRConfig<A>) -> Result<Self> {
403        // E6: the history window comes from the configuration now.
404        let window = config.history_window_size.clamp(4, MAX_METRIC_HISTORY);
405        Ok(Self {
406            magnitude_history: VecDeque::with_capacity(window),
407            direction_variance_history: VecDeque::with_capacity(window),
408            norm_statistics: GradientNormStatistics::default(),
409            snr_estimator: SignalToNoiseEstimator::default(),
410            staleness_detector: GradientStalenessDetector::default(),
411        })
412    }
413
414    /// Fold a gradient into the rolling statistics without producing a vote.
415    pub(crate) fn observe_only(&mut self, gradients: &Array1<A>) {
416        let magnitude = gradients
417            .iter()
418            .map(|&g| g * g)
419            .fold(A::zero(), |acc, x| acc + x)
420            .sqrt();
421        self.push_magnitude(magnitude);
422    }
423
424    fn push_magnitude(&mut self, magnitude: A) {
425        self.magnitude_history.push_back(magnitude);
426        while self.magnitude_history.len() > MAX_METRIC_HISTORY {
427            self.magnitude_history.pop_front();
428        }
429        self.staleness_detector
430            .gradient_timestamps
431            .push_back(Instant::now());
432        while self.staleness_detector.gradient_timestamps.len() > MAX_METRIC_HISTORY {
433            self.staleness_detector.gradient_timestamps.pop_front();
434        }
435        self.refresh_statistics();
436    }
437
438    /// Real moments, percentiles, lag-1 autocorrelation and signal-to-noise
439    /// estimate over the retained magnitudes (all of which were left at their
440    /// `Default` zeros before).
441    fn refresh_statistics(&mut self) {
442        let values: Vec<f64> = self
443            .magnitude_history
444            .iter()
445            .map(|value| from_scalar(*value))
446            .collect();
447        if values.is_empty() {
448            return;
449        }
450        let m = mean(&values);
451        let var = variance(&values);
452        let std_dev = var.sqrt();
453
454        self.norm_statistics.mean = to_scalar(m);
455        self.norm_statistics.variance = to_scalar(var);
456        if std_dev > f64::EPSILON {
457            let third = values.iter().map(|v| (v - m).powi(3)).sum::<f64>() / values.len() as f64;
458            let fourth = values.iter().map(|v| (v - m).powi(4)).sum::<f64>() / values.len() as f64;
459            self.norm_statistics.skewness = to_scalar(third / std_dev.powi(3));
460            self.norm_statistics.kurtosis = to_scalar(fourth / var.powi(2) - 3.0);
461        }
462
463        let mut sorted = values.clone();
464        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
465        let last = sorted.len() - 1;
466        self.norm_statistics.percentiles = [0.05, 0.25, 0.50, 0.75, 0.95]
467            .iter()
468            .map(|q| to_scalar(sorted[((sorted.len() as f64 * q) as usize).min(last)]))
469            .collect();
470
471        if values.len() >= 3 && var > f64::EPSILON {
472            let numerator: f64 = values
473                .windows(2)
474                .map(|pair| (pair[0] - m) * (pair[1] - m))
475                .sum();
476            let denominator: f64 = values.iter().map(|v| (v - m).powi(2)).sum();
477            self.norm_statistics.autocorrelation =
478                to_scalar((numerator / denominator).clamp(-1.0, 1.0));
479        }
480
481        // Signal-to-noise: the mean magnitude against its own dispersion.
482        self.snr_estimator.signal_estimate = to_scalar(m);
483        self.snr_estimator.noise_estimate = to_scalar(std_dev);
484        let snr = if std_dev > f64::EPSILON {
485            m / std_dev
486        } else {
487            0.0
488        };
489        self.snr_estimator.snr_history.push_back(to_scalar(snr));
490        while self.snr_estimator.snr_history.len() > MAX_METRIC_HISTORY {
491            self.snr_estimator.snr_history.pop_front();
492        }
493    }
494
495    pub(crate) fn generate_signal(
496        &mut self,
497        gradients: &Array1<A>,
498        _step: usize,
499    ) -> Result<SignalVote<A>> {
500        let magnitude = gradients
501            .iter()
502            .map(|&g| g * g)
503            .fold(A::zero(), |acc, x| acc + x)
504            .sqrt();
505        self.push_magnitude(magnitude);
506
507        let magnitude_value = from_scalar(magnitude);
508        if !magnitude_value.is_finite() {
509            return Err(crate::error::OptimError::InvalidParameter(
510                "gradient magnitude is not finite".to_string(),
511            ));
512        }
513
514        // Scale-free adaptation: compare this magnitude with the median of the
515        // retained history rather than against absolute constants, so the signal
516        // works for any problem scale.
517        let median = self
518            .norm_statistics
519            .percentiles
520            .get(2)
521            .map(|value| from_scalar(*value))
522            .unwrap_or(magnitude_value);
523        let snr = from_scalar(self.snr_estimator.signal_estimate)
524            / from_scalar(self.snr_estimator.noise_estimate).max(f64::EPSILON);
525
526        let recommended = if median <= f64::EPSILON {
527            1.0
528        } else {
529            let ratio = magnitude_value / median;
530            // A gradient much larger than usual means the step is too big.
531            (1.0 / ratio.clamp(0.5, 2.0)).clamp(0.8, 1.2)
532        };
533
534        // Confidence rises with the amount of history and the signal-to-noise
535        // ratio, rather than being the hardcoded 0.7 it used to be.
536        let coverage = (self.magnitude_history.len() as f64 / 32.0).min(1.0);
537        let confidence = (0.5 * coverage + 0.5 * (snr / (1.0 + snr))).clamp(0.0, 1.0);
538
539        Ok(SignalVote {
540            signal_type: AdaptationSignalType::GradientMagnitude,
541            recommended_lr_change: to_scalar(recommended),
542            confidence: to_scalar(confidence),
543            reasoning: format!(
544                "gradient magnitude {magnitude_value:.6} vs median {median:.6}, snr {snr:.3}"
545            ),
546            timestamp: Instant::now(),
547        })
548    }
549
550    pub(crate) fn reset(&mut self) {
551        self.magnitude_history.clear();
552        self.direction_variance_history.clear();
553        self.norm_statistics = GradientNormStatistics::default();
554        self.snr_estimator = SignalToNoiseEstimator::default();
555        self.staleness_detector = GradientStalenessDetector::default();
556    }
557}
558
559// ---------------------------------------------------------------------------
560// performance-based signal
561// ---------------------------------------------------------------------------
562
563impl<A: Float + Default + Clone + Send + Sync> PerformanceBasedAdapter<A> {
564    pub(crate) fn new(config: &AdaptiveLRConfig<A>) -> Result<Self> {
565        // E6: the plateau band scales with the configured sensitivity.
566        let plateau_detector = PlateauDetector {
567            plateau_threshold: config.adaptation_sensitivity,
568            min_plateau_duration: config.adaptation_frequency.max(2),
569            ..PlateauDetector::default()
570        };
571
572        let trend_analyzer = PerformanceTrendAnalyzer {
573            trend_detection_window: config.history_window_size.clamp(4, 128),
574            ..PerformanceTrendAnalyzer::default()
575        };
576
577        Ok(Self {
578            metric_history: HashMap::new(),
579            trend_analyzer,
580            plateau_detector,
581            overfitting_detector: OverfittingDetector::default(),
582            efficiency_tracker: LearningEfficiencyTracker::default(),
583        })
584    }
585
586    /// Fold a loss into the rolling history without producing a vote.
587    pub(crate) fn observe_only(&mut self, loss: A) {
588        let history = self.metric_history.entry("loss".to_string()).or_default();
589        history.push_back(loss);
590        while history.len() > MAX_METRIC_HISTORY {
591            history.pop_front();
592        }
593    }
594
595    pub(crate) fn generate_signal(
596        &mut self,
597        loss: A,
598        metrics: &HashMap<String, A>,
599        _step: usize,
600    ) -> Result<SignalVote<A>> {
601        self.observe_only(loss);
602        for (name, value) in metrics {
603            let history = self.metric_history.entry(name.clone()).or_default();
604            history.push_back(*value);
605            while history.len() > MAX_METRIC_HISTORY {
606                history.pop_front();
607            }
608        }
609
610        let losses: Vec<f64> = self
611            .metric_history
612            .get("loss")
613            .map(|history| history.iter().map(|value| from_scalar(*value)).collect())
614            .unwrap_or_default();
615
616        let window = self.trend_analyzer.trend_detection_window.max(4);
617        let recent: Vec<f64> = losses.iter().rev().take(window).rev().copied().collect();
618
619        if recent.len() < 2 {
620            return Err(crate::error::OptimError::InvalidState(
621                "not enough loss history for a performance signal".to_string(),
622            ));
623        }
624
625        let trend = slope(&recent);
626        let spread = variance(&recent).sqrt();
627        let level = mean(&recent).abs().max(f64::EPSILON);
628        let relative_trend = trend / level;
629        let relative_spread = spread / level;
630
631        // Plateau detection over the retained window.
632        // `adaptation_sensitivity` scales a 1% relative-trend band; using it
633        // directly as the band would make a sensitivity of 1.0 classify a 99%
634        // relative trend as a plateau.
635        let plateau_band =
636            (0.01 * from_scalar(self.plateau_detector.plateau_threshold).abs()).clamp(1e-6, 0.5);
637        if relative_trend.abs() < plateau_band {
638            self.plateau_detector.current_plateau_length += 1;
639        } else {
640            self.plateau_detector.current_plateau_length = 0;
641        }
642        let on_plateau = self.plateau_detector.current_plateau_length
643            >= self.plateau_detector.min_plateau_duration;
644        self.plateau_detector.plateau_confidence = to_scalar(
645            (self.plateau_detector.current_plateau_length as f64
646                / self.plateau_detector.min_plateau_duration.max(1) as f64)
647                .min(1.0),
648        );
649
650        // Overfitting detection, when a validation loss is reported.
651        self.overfitting_detector.train_loss_history.push_back(loss);
652        while self.overfitting_detector.train_loss_history.len() > MAX_METRIC_HISTORY {
653            self.overfitting_detector.train_loss_history.pop_front();
654        }
655        let mut overfitting = false;
656        if let Some(validation) = metrics.get("val_loss") {
657            self.overfitting_detector
658                .val_loss_history
659                .push_back(*validation);
660            while self.overfitting_detector.val_loss_history.len() > MAX_METRIC_HISTORY {
661                self.overfitting_detector.val_loss_history.pop_front();
662            }
663            let validation_values: Vec<f64> = self
664                .overfitting_detector
665                .val_loss_history
666                .iter()
667                .map(|value| from_scalar(*value))
668                .collect();
669            if validation_values.len() >= 4 {
670                // Training improving while validation degrades.
671                overfitting = relative_trend < 0.0 && slope(&validation_values) > 0.0;
672            }
673        }
674
675        // Learning efficiency: loss reduction per step over the window.
676        if recent.len() >= 2 {
677            let reduction = recent[0] - recent[recent.len() - 1];
678            self.efficiency_tracker
679                .loss_reduction_per_step
680                .push_back(to_scalar(reduction / recent.len() as f64));
681            while self.efficiency_tracker.loss_reduction_per_step.len() > MAX_METRIC_HISTORY {
682                self.efficiency_tracker.loss_reduction_per_step.pop_front();
683            }
684            self.efficiency_tracker.efficiency_score = to_scalar(mean(
685                &self
686                    .efficiency_tracker
687                    .loss_reduction_per_step
688                    .iter()
689                    .map(|value| from_scalar(*value))
690                    .collect::<Vec<f64>>(),
691            ));
692        }
693
694        let trend_type = if overfitting {
695            TrendType::Degrading
696        } else if on_plateau {
697            TrendType::Plateau
698        } else if relative_spread > 0.5 {
699            TrendType::Volatile
700        } else if relative_trend < 0.0 {
701            TrendType::Improving
702        } else {
703            TrendType::Degrading
704        };
705        self.trend_analyzer.trend_types = vec![trend_type];
706        self.trend_analyzer.trend_strength = to_scalar(relative_trend.abs().min(1.0));
707        self.efficiency_tracker.efficiency_trend = trend_type;
708
709        let recommended = match trend_type {
710            // Stuck: a larger step may escape the plateau.
711            TrendType::Plateau => 1.1,
712            // Loss rising or validation diverging: shorten the step.
713            TrendType::Degrading => 0.9,
714            // Oscillating badly: shorten the step more aggressively.
715            TrendType::Volatile => 0.8,
716            // Making progress: nudge upwards.
717            TrendType::Improving => 1.02,
718            TrendType::Oscillating => 0.85,
719        };
720
721        let coverage = (recent.len() as f64 / window as f64).min(1.0);
722        let confidence = (0.4 + 0.6 * coverage).clamp(0.0, 1.0);
723
724        Ok(SignalVote {
725            signal_type: AdaptationSignalType::LossProgression,
726            recommended_lr_change: to_scalar(recommended),
727            confidence: to_scalar(confidence),
728            reasoning: format!(
729                "{trend_type:?}: relative trend {relative_trend:.5}, spread {relative_spread:.5}"
730            ),
731            timestamp: Instant::now(),
732        })
733    }
734
735    pub(crate) fn reset(&mut self) {
736        self.metric_history.clear();
737        self.plateau_detector.current_plateau_length = 0;
738        self.overfitting_detector.train_loss_history.clear();
739        self.overfitting_detector.val_loss_history.clear();
740        self.efficiency_tracker.loss_reduction_per_step.clear();
741    }
742}
743
744// ---------------------------------------------------------------------------
745// E3: drift-aware signal backed by the real detectors
746// ---------------------------------------------------------------------------
747
748/// The real detector behind [`ConceptDriftDetector`], selected by
749/// [`DriftDetectionMethod`].
750#[derive(Debug, Clone)]
751pub enum LossDriftDetector<A: Float + Send + Sync> {
752    /// Page-Hinkley cumulative-sum test
753    PageHinkley(crate::streaming::concept_drift::PageHinkleyDetector<A>),
754    /// Adaptive windowing
755    Adwin(crate::streaming::concept_drift::AdwinDetector<A>),
756    /// Drift detection method over a running-mean error indicator
757    Ddm {
758        detector: crate::streaming::concept_drift::DdmDetector<A>,
759        running_mean: A,
760        samples: usize,
761    },
762}
763
764impl<A: Float + std::iter::Sum + Send + Sync> LossDriftDetector<A> {
765    fn for_method(method: DriftDetectionMethod, threshold: A, window: usize) -> Self {
766        let warning = threshold * A::from(0.5).unwrap_or_else(A::one);
767        match method {
768            DriftDetectionMethod::PageHinkley => LossDriftDetector::PageHinkley(
769                crate::streaming::concept_drift::PageHinkleyDetector::new(threshold, warning),
770            ),
771            DriftDetectionMethod::ADWIN | DriftDetectionMethod::KSWIN => {
772                // Both are windowing tests; ADWIN's Hoeffding cut is the real
773                // implementation available here, and its confidence parameter is
774                // derived from the configured threshold.
775                let lower = A::from(1e-9).unwrap_or_else(A::zero);
776                let upper = A::from(0.5).unwrap_or_else(A::one);
777                let delta = if threshold > A::zero() {
778                    (A::one() / threshold).clamp(lower, upper)
779                } else {
780                    upper
781                };
782                LossDriftDetector::Adwin(crate::streaming::concept_drift::AdwinDetector::new(
783                    delta,
784                    window.max(16),
785                ))
786            }
787            DriftDetectionMethod::DDM
788            | DriftDetectionMethod::EDDM
789            | DriftDetectionMethod::Statistical => LossDriftDetector::Ddm {
790                detector: crate::streaming::concept_drift::DdmDetector::with_warmup(
791                    window.clamp(8, 64),
792                ),
793                running_mean: A::zero(),
794                samples: 0,
795            },
796        }
797    }
798
799    fn update(&mut self, value: A) -> crate::streaming::concept_drift::DriftStatus {
800        match self {
801            LossDriftDetector::PageHinkley(detector) => detector.update(value),
802            LossDriftDetector::Adwin(detector) => detector.update(value),
803            LossDriftDetector::Ddm {
804                detector,
805                running_mean,
806                samples,
807            } => {
808                *samples += 1;
809                let count = A::from(*samples).unwrap_or_else(A::one);
810                *running_mean = *running_mean + (value - *running_mean) / count;
811                detector.update(value > *running_mean)
812            }
813        }
814    }
815}
816
817impl<A: Float + Default + Clone + std::iter::Sum + Send + Sync> ConceptDriftDetector<A> {
818    /// Build a detector for `method` over a `window`-sample window.
819    pub fn new(method: DriftDetectionMethod, threshold: A, window: usize) -> Self {
820        Self {
821            detection_method: method,
822            drift_threshold: threshold,
823            window_size: window,
824            drift_confidence: A::zero(),
825            last_drift_time: None,
826            inner: LossDriftDetector::for_method(method, threshold, window),
827        }
828    }
829
830    /// Feed a value and report whether drift was detected.
831    pub fn update(&mut self, value: A) -> crate::streaming::concept_drift::DriftStatus {
832        let status = self.inner.update(value);
833        self.drift_confidence = match status {
834            crate::streaming::concept_drift::DriftStatus::Drift => A::one(),
835            crate::streaming::concept_drift::DriftStatus::Warning => {
836                A::from(0.5).unwrap_or_else(A::zero)
837            }
838            crate::streaming::concept_drift::DriftStatus::Stable => A::zero(),
839        };
840        if status == crate::streaming::concept_drift::DriftStatus::Drift {
841            self.last_drift_time = Some(Instant::now());
842        }
843        status
844    }
845
846    /// Confidence of the most recent verdict.
847    pub fn confidence(&self) -> A {
848        self.drift_confidence
849    }
850
851    /// When drift was last detected.
852    pub fn last_drift_time(&self) -> Option<Instant> {
853        self.last_drift_time
854    }
855}
856
857impl<A: Float + Default + Clone + std::iter::Sum + Send + Sync> DriftAwareAdapter<A> {
858    pub(crate) fn new(config: &AdaptiveLRConfig<A>) -> Result<Self> {
859        // E3/E6: three genuinely different detectors over the same stream, sized
860        // from the configuration, instead of an empty vector.
861        let threshold = A::from(3.0).unwrap_or_else(A::one);
862        let window = config.history_window_size.clamp(16, 512);
863        let drift_detectors = vec![
864            ConceptDriftDetector::new(DriftDetectionMethod::PageHinkley, threshold, window),
865            ConceptDriftDetector::new(DriftDetectionMethod::ADWIN, threshold, window),
866            ConceptDriftDetector::new(DriftDetectionMethod::DDM, threshold, window),
867        ];
868
869        let distribution_tracker = DistributionTracker {
870            ..DistributionTracker::default()
871        };
872
873        let adaptation_speed = AdaptationSpeedController {
874            base_adaptation_rate: config.adaptation_sensitivity,
875            current_adaptation_rate: config.adaptation_sensitivity,
876            ..AdaptationSpeedController::default()
877        };
878
879        Ok(Self {
880            drift_detectors,
881            distribution_tracker,
882            adaptation_speed,
883            drift_severity: DriftSeverityAssessor::default(),
884        })
885    }
886
887    pub(crate) fn generate_signal(
888        &mut self,
889        gradients: &Array1<A>,
890        _step: usize,
891    ) -> Result<SignalVote<A>> {
892        let magnitude = gradients
893            .iter()
894            .map(|&g| g * g)
895            .fold(A::zero(), |acc, x| acc + x)
896            .sqrt();
897        if !from_scalar(magnitude).is_finite() {
898            return Err(crate::error::OptimError::InvalidParameter(
899                "gradient magnitude is not finite".to_string(),
900            ));
901        }
902
903        let mut drift_votes = 0usize;
904        let mut warning_votes = 0usize;
905        for detector in self.drift_detectors.iter_mut() {
906            match detector.update(magnitude) {
907                crate::streaming::concept_drift::DriftStatus::Drift => drift_votes += 1,
908                crate::streaming::concept_drift::DriftStatus::Warning => warning_votes += 1,
909                crate::streaming::concept_drift::DriftStatus::Stable => {}
910            }
911        }
912
913        // Distribution shift over the gradient coordinates, measured as a real
914        // KL divergence between the current and the reference histogram.
915        let divergence = self.distribution_tracker.observe(gradients);
916
917        let detectors = self.drift_detectors.len().max(1) as f64;
918        let agreement = (drift_votes as f64 + 0.5 * warning_votes as f64) / detectors;
919        let severity = self.drift_severity.assess(agreement, divergence);
920        let recommended = from_scalar(severity.recommended_lr_adjustment);
921        let rate = self.adaptation_speed.update(agreement);
922
923        // The severity's recommendation is applied at the controller's current
924        // adaptation rate, so a fast-moving stream reacts harder.
925        let scaled = 1.0 + (recommended - 1.0) * rate;
926        let confidence = (0.3 + 0.7 * agreement).clamp(0.0, 1.0);
927
928        Ok(SignalVote {
929            signal_type: AdaptationSignalType::ConceptDrift,
930            recommended_lr_change: to_scalar(scaled),
931            confidence: to_scalar(confidence),
932            reasoning: format!(
933                "{:?} drift ({drift_votes}/{} detectors, KL {:.4})",
934                severity.level,
935                self.drift_detectors.len(),
936                divergence
937            ),
938            timestamp: Instant::now(),
939        })
940    }
941
942    pub(crate) fn reset(&mut self) {
943        for detector in self.drift_detectors.iter_mut() {
944            detector.drift_confidence = A::zero();
945            detector.last_drift_time = None;
946            detector.inner = LossDriftDetector::for_method(
947                detector.detection_method,
948                detector.drift_threshold,
949                detector.window_size,
950            );
951        }
952        self.distribution_tracker = DistributionTracker::default();
953        self.drift_severity = DriftSeverityAssessor::default();
954    }
955
956    /// Whether any detector currently reports drift.
957    pub fn drift_detected(&self) -> bool {
958        self.drift_detectors
959            .iter()
960            .any(|detector| from_scalar(detector.drift_confidence) >= 1.0)
961    }
962}
963
964/// Number of histogram bins used for the distribution-divergence estimate.
965const DISTRIBUTION_BINS: usize = 16;
966
967/// Half-width, in reference standard deviations, of the binned range.
968const DISTRIBUTION_Z_RANGE: f64 = 4.0;
969
970impl<A: Float + Default + Send + Sync> DistributionTracker<A> {
971    /// Fold the gradient coordinates into a histogram over the *reference*
972    /// z-scale and return the KL divergence from the running reference
973    /// distribution.
974    ///
975    /// Binning against the reference mean and standard deviation (rather than
976    /// each observation's own min/max) is what makes the measure sensitive to a
977    /// shift: a per-observation range would renormalise the shift away and
978    /// report zero divergence for an arbitrarily displaced distribution.
979    pub(crate) fn observe(&mut self, gradients: &Array1<A>) -> f64 {
980        let values: Vec<f64> = gradients.iter().map(|value| from_scalar(*value)).collect();
981        if values.len() < 2 || values.iter().any(|value| !value.is_finite()) {
982            return 0.0;
983        }
984
985        let reference =
986            self.feature_distributions
987                .entry(0)
988                .or_insert_with(|| FeatureDistribution {
989                    mean: A::zero(),
990                    variance: A::zero(),
991                    histogram: vec![A::zero(); DISTRIBUTION_BINS],
992                    last_update: Instant::now(),
993                });
994
995        let reference_mean = from_scalar(reference.mean);
996        let reference_std = from_scalar(reference.variance).max(0.0).sqrt();
997        let scale = if reference_std > f64::EPSILON {
998            reference_std
999        } else {
1000            variance(&values).sqrt().max(f64::EPSILON)
1001        };
1002
1003        let width = 2.0 * DISTRIBUTION_Z_RANGE / DISTRIBUTION_BINS as f64;
1004        let mut counts = [0.0f64; DISTRIBUTION_BINS];
1005        for value in &values {
1006            let z = ((value - reference_mean) / scale)
1007                .clamp(-DISTRIBUTION_Z_RANGE, DISTRIBUTION_Z_RANGE);
1008            let index = (((z + DISTRIBUTION_Z_RANGE) / width) as usize).min(DISTRIBUTION_BINS - 1);
1009            counts[index] += 1.0;
1010        }
1011        let total: f64 = counts.iter().sum();
1012        let current: Vec<f64> = counts.iter().map(|count| count / total).collect();
1013
1014        // KL(current || reference), with Laplace smoothing so a zero reference
1015        // bin cannot produce an infinite divergence.
1016        let smoothing = 1.0 / DISTRIBUTION_BINS as f64;
1017        let mut divergence = 0.0;
1018        let mut initialised = false;
1019        for (index, probability) in current.iter().enumerate() {
1020            let previous = from_scalar(reference.histogram[index]);
1021            if previous > 0.0 {
1022                initialised = true;
1023            }
1024            let q = (previous + smoothing) / (1.0 + 1.0);
1025            let p = (probability + smoothing) / (1.0 + 1.0);
1026            if p > 0.0 && q > 0.0 {
1027                divergence += p * (p / q).ln();
1028            }
1029        }
1030
1031        // Update the reference with an exponential moving average.
1032        for (index, probability) in current.iter().enumerate() {
1033            let previous = from_scalar(reference.histogram[index]);
1034            reference.histogram[index] = to_scalar(0.9 * previous + 0.1 * probability);
1035        }
1036        // Track the reference location and spread with the same EMA so the
1037        // z-scale follows the stream slowly rather than jumping onto it.
1038        reference.mean = to_scalar(0.9 * reference_mean + 0.1 * mean(&values));
1039        let observed_variance = variance(&values);
1040        let previous_variance = from_scalar(reference.variance);
1041        reference.variance = to_scalar(if previous_variance > 0.0 {
1042            0.9 * previous_variance + 0.1 * observed_variance
1043        } else {
1044            observed_variance
1045        });
1046        reference.last_update = Instant::now();
1047
1048        let divergence = if initialised && divergence.is_finite() {
1049            divergence.max(0.0)
1050        } else {
1051            // The first observation has nothing to diverge from.
1052            0.0
1053        };
1054        self.distribution_drift_score = to_scalar(divergence);
1055        divergence
1056    }
1057}
1058
1059impl<A: Float + Default + Send + Sync> AdaptationSpeedController<A> {
1060    /// Accelerate while drift persists, decelerate while it does not, with
1061    /// momentum so the rate does not jump.
1062    pub(crate) fn update(&mut self, drift_agreement: f64) -> f64 {
1063        let base = from_scalar(self.base_adaptation_rate).max(1e-6);
1064        let acceleration = from_scalar(self.acceleration_factor).max(1.0);
1065        let deceleration = from_scalar(self.deceleration_factor).clamp(0.0, 1.0);
1066        let current = from_scalar(self.current_adaptation_rate).max(1e-6);
1067
1068        let target = if drift_agreement > 0.0 {
1069            current * acceleration
1070        } else {
1071            current * deceleration
1072        };
1073        let momentum = from_scalar(self.momentum).clamp(0.0, 0.95);
1074        let updated = (momentum * current + (1.0 - momentum) * target).clamp(base * 0.1, 1.0);
1075        self.current_adaptation_rate = to_scalar(updated);
1076        self.momentum = to_scalar(0.9 * momentum + 0.1 * drift_agreement.clamp(0.0, 1.0));
1077        updated
1078    }
1079}
1080
1081impl<A: Float + Default + Send + Sync> DriftSeverityAssessor<A> {
1082    /// Classify drift severity from detector agreement and distribution shift.
1083    pub(crate) fn assess(&mut self, agreement: f64, divergence: f64) -> DriftSeverityLevel<A> {
1084        let magnitude = (agreement + divergence.min(1.0)) / 2.0;
1085        // Dead band: residual sampling noise in the divergence estimate must
1086        // not be reported as drift on an otherwise stationary stream.
1087        let magnitude = if magnitude < 0.05 { 0.0 } else { magnitude };
1088        let (level, adjustment) = if magnitude >= 0.75 {
1089            (DriftSeverity::Critical, 2.0)
1090        } else if magnitude >= 0.5 {
1091            (DriftSeverity::Severe, 1.5)
1092        } else if magnitude >= 0.25 {
1093            (DriftSeverity::Moderate, 1.2)
1094        } else if magnitude > 0.0 {
1095            (DriftSeverity::Mild, 1.05)
1096        } else {
1097            (DriftSeverity::None, 1.0)
1098        };
1099
1100        let assessed = DriftSeverityLevel {
1101            level,
1102            recommended_lr_adjustment: to_scalar(adjustment),
1103        };
1104        self.current_severity = assessed.clone();
1105        self.severity_history.push_back(assessed.clone());
1106        while self.severity_history.len() > MAX_METRIC_HISTORY {
1107            self.severity_history.pop_front();
1108        }
1109        if self.severity_levels.len() < 5
1110            && !self
1111                .severity_levels
1112                .iter()
1113                .any(|existing| existing.level == level)
1114        {
1115            self.severity_levels.push(assessed.clone());
1116        }
1117        assessed
1118    }
1119}
1120
1121// ---------------------------------------------------------------------------
1122// E4: resource-aware signal from real measurements only
1123// ---------------------------------------------------------------------------
1124
1125impl<A: Float + Default + Clone + Send + Sync> ResourceAwareAdapter<A> {
1126    pub(crate) fn new(config: &AdaptiveLRConfig<A>) -> Result<Self> {
1127        let compute_tracker = ComputationTimeTracker {
1128            time_budget: config.step_time_budget,
1129            ..ComputationTimeTracker::default()
1130        };
1131
1132        Ok(Self {
1133            memory_tracker: MemoryUsageTracker::default(),
1134            compute_tracker,
1135            energy_tracker: EnergyConsumptionTracker::default(),
1136            throughput_requirements: ThroughputRequirements {
1137                min_samples_per_second: A::zero(),
1138                // No throughput has been observed yet; a caller supplies it
1139                // through `record_throughput`.
1140                current_throughput: A::zero(),
1141                throughput_deficit: A::zero(),
1142            },
1143            budget_manager: ResourceBudgetManager {
1144                memory_budget_mb: config.memory_budget_mb.unwrap_or(0.0),
1145                compute_budget_seconds: config
1146                    .step_time_budget
1147                    .map(|budget| budget.as_secs_f64())
1148                    .unwrap_or(0.0),
1149                budget_utilization: A::zero(),
1150                budget_violations: 0,
1151            },
1152        })
1153    }
1154
1155    /// Record a measured step duration.
1156    pub(crate) fn record_step_time(&mut self, elapsed: Duration, budget: Option<Duration>) {
1157        self.compute_tracker.time_budget = budget;
1158        self.compute_tracker.step_times.push_back(elapsed);
1159        while self.compute_tracker.step_times.len() > MAX_METRIC_HISTORY {
1160            self.compute_tracker.step_times.pop_front();
1161        }
1162        let seconds: Vec<f64> = self
1163            .compute_tracker
1164            .step_times
1165            .iter()
1166            .map(|duration| duration.as_secs_f64())
1167            .collect();
1168        let average = mean(&seconds);
1169        self.compute_tracker.average_step_time = Duration::from_secs_f64(average.max(0.0));
1170        self.compute_tracker.time_pressure = match budget {
1171            Some(budget) if budget.as_secs_f64() > 0.0 => {
1172                let pressure = average / budget.as_secs_f64();
1173                if pressure > 1.0 {
1174                    self.budget_manager.budget_violations += 1;
1175                }
1176                Some(pressure)
1177            }
1178            _ => None,
1179        };
1180        if self.budget_manager.compute_budget_seconds > 0.0 {
1181            self.budget_manager.budget_utilization = to_scalar(
1182                (average / self.budget_manager.compute_budget_seconds).clamp(0.0, f64::MAX),
1183            );
1184        }
1185        // Throughput follows directly from the measured step time.
1186        if average > 0.0 {
1187            self.throughput_requirements.current_throughput = to_scalar(1.0 / average);
1188            self.refresh_throughput_deficit();
1189        }
1190    }
1191
1192    /// Record real memory usage; without a budget there is no pressure to report.
1193    pub(crate) fn record_memory_usage(&mut self, usage_mb: f64, budget_mb: Option<f64>) {
1194        self.memory_tracker.current_usage_mb = usage_mb;
1195        self.memory_tracker.peak_usage_mb = self.memory_tracker.peak_usage_mb.max(usage_mb);
1196        self.memory_tracker.usage_history.push_back(usage_mb);
1197        while self.memory_tracker.usage_history.len() > MAX_METRIC_HISTORY {
1198            self.memory_tracker.usage_history.pop_front();
1199        }
1200        if let Some(budget) = budget_mb.filter(|budget| *budget > 0.0) {
1201            self.budget_manager.memory_budget_mb = budget;
1202            let pressure = usage_mb / budget;
1203            if pressure > 1.0 {
1204                self.budget_manager.budget_violations += 1;
1205            }
1206            self.memory_tracker.memory_pressure = Some(pressure);
1207        } else {
1208            self.memory_tracker.memory_pressure = None;
1209        }
1210    }
1211
1212    /// Record measured energy consumption for the most recent step.
1213    pub(crate) fn record_energy(&mut self, joules: f64) {
1214        self.energy_tracker.energy_per_step.push_back(joules);
1215        while self.energy_tracker.energy_per_step.len() > MAX_METRIC_HISTORY {
1216            self.energy_tracker.energy_per_step.pop_front();
1217        }
1218        self.energy_tracker.cumulative_energy += joules;
1219        let average = mean(
1220            &self
1221                .energy_tracker
1222                .energy_per_step
1223                .iter()
1224                .copied()
1225                .collect::<Vec<f64>>(),
1226        );
1227        self.energy_tracker.energy_efficiency = if average > 0.0 {
1228            Some(1.0 / average)
1229        } else {
1230            None
1231        };
1232    }
1233
1234    /// Record the observed sample throughput and the requirement it must meet.
1235    pub(crate) fn record_throughput(&mut self, samples_per_second: A) {
1236        self.throughput_requirements.current_throughput = samples_per_second;
1237        self.refresh_throughput_deficit();
1238    }
1239
1240    /// Set the minimum throughput the deployment has to satisfy.
1241    ///
1242    /// Only the minimum is modelled: `throughput_deficit` — the one quantity the
1243    /// resource-aware adapter acts on — is measured against it, and the separate
1244    /// "target" the signature used to take had no reader anywhere.
1245    pub fn set_throughput_requirement(&mut self, minimum: A) {
1246        self.throughput_requirements.min_samples_per_second = minimum;
1247        self.refresh_throughput_deficit();
1248    }
1249
1250    fn refresh_throughput_deficit(&mut self) {
1251        let minimum = from_scalar(self.throughput_requirements.min_samples_per_second);
1252        let current = from_scalar(self.throughput_requirements.current_throughput);
1253        self.throughput_requirements.throughput_deficit = to_scalar((minimum - current).max(0.0));
1254    }
1255
1256    pub(crate) fn generate_signal(&mut self, _step: usize) -> Result<SignalVote<A>> {
1257        // E4: only measurements that actually exist take part. Previously the
1258        // never-written `memory_pressure` of 0.0 was read as "plenty of spare
1259        // memory" and pushed the learning rate up on every step.
1260        let mut terms: Vec<(f64, String)> = Vec::new();
1261
1262        if let Some(pressure) = self.memory_tracker.memory_pressure {
1263            let recommended = if pressure > 0.8 {
1264                0.9
1265            } else if pressure < 0.3 {
1266                1.05
1267            } else {
1268                1.0
1269            };
1270            terms.push((recommended, format!("memory pressure {pressure:.2}")));
1271        }
1272
1273        if let Some(pressure) = self.compute_tracker.time_pressure {
1274            // Over the step budget: a smaller learning rate does not make steps
1275            // faster, but it lets the deployment take more of them before
1276            // diverging, which is the trade this signal is for.
1277            let recommended = if pressure > 1.0 {
1278                0.95
1279            } else if pressure < 0.5 {
1280                1.02
1281            } else {
1282                1.0
1283            };
1284            terms.push((recommended, format!("time pressure {pressure:.2}")));
1285        }
1286
1287        let deficit = from_scalar(self.throughput_requirements.throughput_deficit);
1288        if deficit > 0.0 {
1289            terms.push((0.95, format!("throughput deficit {deficit:.2}/s")));
1290        }
1291
1292        if terms.is_empty() {
1293            return Err(crate::error::OptimError::InvalidState(
1294                "no resource measurements have been reported, so no resource signal can be \
1295                 produced"
1296                    .to_string(),
1297            ));
1298        }
1299
1300        let recommended = mean(&terms.iter().map(|(value, _)| *value).collect::<Vec<f64>>());
1301        // Confidence grows with the number of independent measurements backing
1302        // the vote (three is the maximum this adapter can observe).
1303        let confidence = (terms.len() as f64 / 3.0).clamp(0.0, 1.0);
1304
1305        Ok(SignalVote {
1306            signal_type: AdaptationSignalType::ResourceUtilization,
1307            recommended_lr_change: to_scalar(recommended),
1308            confidence: to_scalar(confidence),
1309            reasoning: terms
1310                .iter()
1311                .map(|(_, reason)| reason.clone())
1312                .collect::<Vec<String>>()
1313                .join(", "),
1314            timestamp: Instant::now(),
1315        })
1316    }
1317
1318    pub(crate) fn reset(&mut self) {
1319        self.memory_tracker = MemoryUsageTracker::default();
1320        self.compute_tracker = ComputationTimeTracker {
1321            time_budget: self.compute_tracker.time_budget,
1322            ..ComputationTimeTracker::default()
1323        };
1324        self.energy_tracker = EnergyConsumptionTracker::default();
1325        self.budget_manager.budget_violations = 0;
1326        self.budget_manager.budget_utilization = A::zero();
1327    }
1328
1329    /// Budget violations observed so far.
1330    pub(crate) fn budget_violations(&self) -> usize {
1331        self.budget_manager.budget_violations
1332    }
1333}