Skip to main content

optirs_nas/evaluation/
predictor.rs

1//! Performance predictor for NAS evaluation
2//!
3//! Provides models for predicting optimizer performance without full evaluation.
4
5use scirs2_core::ndarray::{Array1, Array2};
6use scirs2_core::numeric::{Float, NumCast};
7use std::collections::HashMap;
8use std::fmt::Debug;
9use std::sync::Mutex;
10use std::time::Instant;
11
12use super::types::*;
13use crate::error::{OptimError, Result};
14use crate::nas_engine::results::EvaluationResults;
15use crate::{EvaluationConfig, EvaluationMetric, OptimizerArchitecture};
16
17/// Fixed length of the architecture feature vector used by the linear predictor.
18///
19/// The feature layout is deterministic (see [`PerformancePredictor::extract_features`])
20/// and includes a leading bias/intercept term so the model can fit a constant offset.
21const FEATURE_DIM: usize = 12;
22
23/// Performance predictor
24///
25/// Implements a genuinely-learned, feature-based online ridge-regression predictor.
26/// An [`OptimizerArchitecture`] is mapped to a fixed-length numeric feature vector and
27/// scored by a linear model squashed through a logistic function into `[0, 1]`. The
28/// model is updated online from observed evaluation results.
29///
30/// # Removed scaffolding
31///
32/// Earlier revisions carried a large tree of neural-network / feature-pipeline /
33/// prediction-cache types around this model: `ModelArchitecture`,
34/// `RegularizationParameters`, `LearningRateSchedule`, `EarlyStoppingState`,
35/// `FeatureExtractor`, `FeatureEngineeringPipeline`, `FeatureScaling`,
36/// `FeatureInteractions`, `PolynomialFeatures`, `FeatureSelection`, `FeatureCache`,
37/// `TrainingMetadata`, `ResourceUsageRecord`, `DataSplits`, `PredictionCache`,
38/// `PredictionResult`, `CacheStatistics`, `CacheConfig`, `UncertaintyEstimator`,
39/// `UncertaintyParameters`, `CalibrationData` and `CalibrationCurve`. Every one of
40/// them was constructed once, stored, and never read again — not one influenced a
41/// single prediction, and none could be built or inspected from outside the module.
42/// They are gone rather than left as a public API that does nothing; what remains is
43/// exactly the state the model uses, and all of it is readable through the accessors
44/// below.
45#[derive(Debug)]
46pub struct PerformancePredictor<T: Float + Debug + Send + Sync + 'static> {
47    /// Predictor model
48    predictor_model: PredictorModel<T>,
49
50    /// Training data
51    training_data: PredictorTrainingData<T>,
52
53    /// Confidence level used to scale the reported interval, in `(0, 1)`.
54    confidence_level: T,
55
56    /// Online learning rate for the ridge / SGD weight update.
57    learning_rate: T,
58
59    /// L2 (ridge) regularization strength applied during the online update.
60    ridge_lambda: T,
61
62    /// Pending feature vectors recorded during `predict_performance`.
63    ///
64    /// Because `predict_performance` takes `&self` and [`EvaluationResults`] does not
65    /// carry the originating architecture, the features observed at prediction time are
66    /// buffered here (FIFO). `update_with_results` then pairs each incoming result's
67    /// `overall_score` target with the corresponding buffered feature vector to form a
68    /// genuine `(features -> target)` training pair. A `Mutex` is used (rather than
69    /// `RefCell`) so the predictor remains `Sync`. Assumption: results are supplied to
70    /// `update_with_results` in the same order their architectures were predicted; any
71    /// surplus results beyond the buffered features are skipped (no architecture to
72    /// reconstruct features from).
73    pending_features: Mutex<Vec<Array1<T>>>,
74}
75
76/// Predictor model
77#[derive(Debug)]
78pub struct PredictorModel<T: Float + Debug + Send + Sync + 'static> {
79    /// Model type
80    model_type: PredictorModelType,
81
82    /// Model parameters
83    parameters: ModelParameters<T>,
84
85    /// Training state
86    training_state: ModelTrainingState<T>,
87}
88
89/// Model parameters
90///
91/// The intercept is carried by feature `[0]` of the feature vector rather than by a
92/// separate bias vector, so the weight matrix is the model's entire parameter set.
93#[derive(Debug)]
94pub struct ModelParameters<T: Float + Debug + Send + Sync + 'static> {
95    /// Linear weights: a single `[FEATURE_DIM, 1]` matrix.
96    weights: Vec<Array2<T>>,
97}
98
99/// Model training state
100#[derive(Debug)]
101pub struct ModelTrainingState<T: Float + Debug + Send + Sync + 'static> {
102    /// Number of online updates applied so far
103    current_epoch: usize,
104
105    /// Squared error of every online update, in application order
106    loss_history: Vec<T>,
107}
108
109/// Predictor training data
110#[derive(Debug)]
111pub struct PredictorTrainingData<T: Float + Debug + Send + Sync + 'static> {
112    /// Architecture features
113    architecture_features: Vec<Array1<T>>,
114
115    /// Performance targets
116    performance_targets: Vec<T>,
117}
118
119// Implementations
120
121impl<T: Float + Debug + Default + Send + Sync> PerformancePredictor<T> {
122    /// Default confidence level used for the reported prediction interval.
123    const DEFAULT_CONFIDENCE_LEVEL: f64 = 0.95;
124
125    pub fn new(_config: &EvaluationConfig) -> Result<Self> {
126        Ok(Self {
127            predictor_model: PredictorModel::new(),
128            training_data: PredictorTrainingData::new(),
129            confidence_level: Self::scalar(Self::DEFAULT_CONFIDENCE_LEVEL),
130            learning_rate: Self::scalar(0.05),
131            ridge_lambda: Self::scalar(1e-4),
132            pending_features: Mutex::new(Vec::new()),
133        })
134    }
135
136    /// Model family this predictor implements.
137    pub fn model_type(&self) -> &PredictorModelType {
138        &self.predictor_model.model_type
139    }
140
141    /// Number of online updates applied so far.
142    pub fn epochs_trained(&self) -> usize {
143        self.predictor_model.training_state.current_epoch
144    }
145
146    /// Squared error of every online update, in application order.
147    pub fn training_loss_history(&self) -> &[T] {
148        &self.predictor_model.training_state.loss_history
149    }
150
151    /// Number of `(features -> target)` pairs the model has been trained on.
152    pub fn observation_count(&self) -> usize {
153        self.num_observations()
154    }
155
156    /// Root-mean-square error of the *current* weights over every training pair
157    /// recorded so far, or `None` before any pair has been observed.
158    ///
159    /// This is a resubstitution error, not a held-out estimate: it says how well
160    /// the model now fits what it has already seen. It is what makes the stored
161    /// training features and targets observable rather than write-only.
162    pub fn training_rmse(&self) -> Option<T> {
163        let targets = &self.training_data.performance_targets;
164        let features = &self.training_data.architecture_features;
165        let n = targets.len().min(features.len());
166        if n == 0 {
167            return None;
168        }
169        let mut sum_sq = T::zero();
170        for i in 0..n {
171            let error = Self::sigmoid(self.raw_score(&features[i])) - targets[i];
172            sum_sq = sum_sq + error * error;
173        }
174        let count: T = NumCast::from(n as f64)?;
175        Some((sum_sq / count).sqrt())
176    }
177
178    /// Confidence level used to widen the reported prediction interval.
179    pub fn confidence_level(&self) -> T {
180        self.confidence_level
181    }
182
183    /// Set the confidence level used for the reported prediction interval.
184    ///
185    /// Rejects values outside the open interval `(0, 1)`: a level of `0` or `1` is
186    /// not a confidence level, and silently clamping it would make the reported
187    /// interval mean something other than what the caller asked for.
188    pub fn set_confidence_level(&mut self, level: T) -> Result<()> {
189        if !(level > T::zero() && level < T::one()) {
190            return Err(OptimError::InvalidParameter(format!(
191                "confidence level must lie strictly inside (0, 1), got {:?}",
192                level
193            )));
194        }
195        self.confidence_level = level;
196        Ok(())
197    }
198
199    /// Convert a finite `f64` constant into `T`, falling back to zero on failure.
200    fn scalar(value: f64) -> T {
201        NumCast::from(value).unwrap_or_else(T::zero)
202    }
203
204    /// Numerically-stable logistic squashing function mapping a raw score to `(0, 1)`.
205    fn sigmoid(raw: T) -> T {
206        let one = T::one();
207        if raw >= T::zero() {
208            let z = (-raw).exp();
209            one / (one + z)
210        } else {
211            let z = raw.exp();
212            z / (one + z)
213        }
214    }
215
216    /// Clamp a value into the closed unit interval `[0, 1]`.
217    fn clamp_unit(value: T) -> T {
218        value.max(T::zero()).min(T::one())
219    }
220
221    /// Deterministic, order-independent content hash of the architecture structure,
222    /// normalized into `[0, 1]`. Provides a stable identity-derived feature without
223    /// depending on `HashMap` iteration order.
224    fn structure_signature(architecture: &OptimizerArchitecture<T>) -> T {
225        // FNV-1a over a canonicalized (sorted) view of the structure entries so the
226        // result is independent of vector ordering noise yet sensitive to content.
227        let mut entries: Vec<&str> = architecture.components.iter().map(|s| s.as_str()).collect();
228        entries.sort_unstable();
229
230        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
231        for entry in entries {
232            for byte in entry.as_bytes() {
233                hash ^= *byte as u64;
234                hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
235            }
236            // Separator so ["ab","c"] and ["a","bc"] differ.
237            hash ^= 0x1f;
238            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
239        }
240
241        // Map the high 24 bits into [0, 1].
242        let bucket = (hash >> 40) as f64;
243        let max = ((1u64 << 24) - 1) as f64;
244        Self::scalar(bucket / max)
245    }
246
247    /// Map an [`OptimizerArchitecture`] to a fixed-length numeric feature vector.
248    ///
249    /// The layout is deterministic and length `FEATURE_DIM`:
250    /// * `[0]`  bias / intercept (always `1`)
251    /// * `[1]`  squashed component count (`structure.len()`)
252    /// * `[2]`  squashed distinct-component-type count
253    /// * `[3]`  squashed parameter count (`parameters.len()`)
254    /// * `[4]`  mean parameter value
255    /// * `[5]`  squashed sum of parameter values
256    /// * `[6]`  maximum parameter value
257    /// * `[7]`  minimum parameter value
258    /// * `[8]`  normalized structure content signature
259    /// * `[9]`  squashed `id` length
260    /// * `[10]` density of recognized optimizer keywords in the structure
261    /// * `[11]` learning-rate hyperparameter (if present, else `0`)
262    fn extract_features(&self, architecture: &OptimizerArchitecture<T>) -> Array1<T> {
263        let mut features = Array1::zeros(FEATURE_DIM);
264
265        // [0] bias term.
266        features[0] = T::one();
267
268        // [1] component count (compressed via a saturating transform to keep the
269        // linear model well-conditioned for large architectures).
270        let n_components = architecture.components.len();
271        features[1] = Self::saturating_count(n_components);
272
273        // [2] distinct component types.
274        let mut distinct: Vec<&str> = architecture.components.iter().map(|s| s.as_str()).collect();
275        distinct.sort_unstable();
276        distinct.dedup();
277        features[2] = Self::saturating_count(distinct.len());
278
279        // Numeric attributes are the union of the architecture's `parameters`
280        // and `hyperparameters` maps: search strategies populate one or the
281        // other depending on how the candidate was produced, and the predictor
282        // must see the same architecture either way.
283        let numeric_values: Vec<f64> = architecture
284            .parameters
285            .values()
286            .chain(architecture.hyperparameters.values())
287            .map(|v| v.to_f64().unwrap_or(0.0))
288            .filter(|v| v.is_finite())
289            .collect();
290
291        // [3] parameter count.
292        features[3] = Self::saturating_count(numeric_values.len());
293
294        // [4..8] parameter value statistics.
295        if numeric_values.is_empty() {
296            features[4] = T::zero();
297            features[5] = T::zero();
298            features[6] = T::zero();
299            features[7] = T::zero();
300        } else {
301            let mut sum = 0.0_f64;
302            let mut max = f64::NEG_INFINITY;
303            let mut min = f64::INFINITY;
304            for &v in &numeric_values {
305                sum += v;
306                if v > max {
307                    max = v;
308                }
309                if v < min {
310                    min = v;
311                }
312            }
313            let count = numeric_values.len() as f64;
314            features[4] = Self::scalar(sum / count);
315            features[5] = Self::scalar((sum.abs()).tanh()); // squashed magnitude
316            features[6] = Self::scalar(max);
317            features[7] = Self::scalar(min);
318        }
319
320        // [8] structure content signature.
321        features[8] = Self::structure_signature(architecture);
322
323        // [9] identifier length (proxy for descriptive complexity).
324        features[9] = Self::saturating_count(architecture.architecture_id.len());
325
326        // [10] recognized optimizer keyword density.
327        if n_components == 0 {
328            features[10] = T::zero();
329        } else {
330            const KEYWORDS: [&str; 7] = [
331                "adam", "sgd", "momentum", "rmsprop", "adagrad", "adamw", "lamb",
332            ];
333            let mut hits = 0usize;
334            for entry in &architecture.components {
335                let lower = entry.to_ascii_lowercase();
336                if KEYWORDS.iter().any(|kw| lower.contains(kw)) {
337                    hits += 1;
338                }
339            }
340            features[10] = Self::scalar(hits as f64 / n_components as f64);
341        }
342
343        // [11] learning-rate hyperparameter if exposed under a common key.
344        let lr = ["learning_rate", "lr"]
345            .iter()
346            .find_map(|key| {
347                architecture
348                    .hyperparameters
349                    .get(*key)
350                    .or_else(|| architecture.parameters.get(*key))
351            })
352            .and_then(|v| v.to_f64())
353            .filter(|v| v.is_finite())
354            .unwrap_or(0.0);
355        features[11] = Self::scalar(lr);
356
357        features
358    }
359
360    /// Saturating, monotonic transform of a non-negative count into `[0, 1)`:
361    /// `n -> n / (n + 1)`. Keeps unbounded counts inside a bounded feature range.
362    fn saturating_count(n: usize) -> T {
363        let nf = n as f64;
364        Self::scalar(nf / (nf + 1.0))
365    }
366
367    /// Compute the linear raw score `w . x` from the stored weight vector.
368    fn raw_score(&self, features: &Array1<T>) -> T {
369        let weights = &self.predictor_model.parameters.weights[0];
370        let mut acc = T::zero();
371        for i in 0..FEATURE_DIM {
372            acc = acc + weights[[i, 0]] * features[i];
373        }
374        acc
375    }
376
377    /// Number of `(features -> target)` pairs the model has been trained on so far.
378    fn num_observations(&self) -> usize {
379        self.training_data.performance_targets.len()
380    }
381
382    /// Estimate the half-width of the prediction confidence interval.
383    ///
384    /// The interval shrinks as more observations accumulate (a variance proxy:
385    /// `base / sqrt(1 + n)`) and is scaled by the configured confidence level so a
386    /// higher requested confidence yields a wider interval. With no training data the
387    /// interval is at its widest, reflecting high epistemic uncertainty.
388    fn confidence_half_width(&self) -> T {
389        let n = self.num_observations();
390        let base = Self::scalar(0.25);
391        let denom = Self::scalar((1.0 + n as f64).sqrt());
392        let spread = base / denom;
393        // Scale by the requested confidence level (e.g. 0.95 -> wider than 0.5).
394        let scale = T::one() + self.confidence_level;
395        spread * scale
396    }
397
398    pub fn predict_performance(
399        &self,
400        architecture: &OptimizerArchitecture<T>,
401    ) -> Result<EvaluationResults<T>> {
402        let start_time = Instant::now();
403
404        // 1. Deterministic feature extraction.
405        let features = self.extract_features(architecture);
406
407        // 2. Linear model -> logistic squashing -> clamp into [0, 1].
408        let raw = self.raw_score(&features);
409        let overall_score = Self::clamp_unit(Self::sigmoid(raw));
410
411        // 3. Uncertainty-derived confidence interval (wider with little data).
412        let half_width = self.confidence_half_width();
413        let lower = Self::clamp_unit(overall_score - half_width);
414        let upper = Self::clamp_unit(overall_score + half_width);
415
416        // 4. Record the features observed at prediction time so a subsequent
417        //    `update_with_results` call can form a genuine training pair. Failure to
418        //    acquire the lock is non-fatal for prediction (we simply skip buffering).
419        if let Ok(mut pending) = self.pending_features.lock() {
420            pending.push(features);
421        }
422
423        // 5. Populate the evaluation results.
424        let mut metric_scores: HashMap<EvaluationMetric, T> = HashMap::new();
425        metric_scores.insert(EvaluationMetric::FinalPerformance, overall_score);
426
427        let mut confidence_intervals: HashMap<EvaluationMetric, (T, T)> = HashMap::new();
428        confidence_intervals.insert(EvaluationMetric::FinalPerformance, (lower, upper));
429
430        Ok(EvaluationResults {
431            metric_scores,
432            overall_score,
433            confidence_intervals,
434            evaluation_time: start_time.elapsed(),
435            success: true,
436            error_message: None,
437            cv_results: None,
438            benchmark_results: HashMap::new(),
439            training_trajectory: Vec::new(),
440        })
441    }
442
443    pub fn update_with_results(&mut self, results: &[EvaluationResults<T>]) -> Result<()> {
444        // Build the (features -> target) training pairs first, releasing the feature
445        // buffer lock before mutating the model. Each successful result's
446        // `overall_score` target is paired (FIFO) with the features recorded at the
447        // corresponding `predict_performance` call. Surplus results with no buffered
448        // features are skipped (no architecture to reconstruct features from).
449        let mut pairs: Vec<(Array1<T>, T)> = Vec::new();
450        {
451            let mut pending = self.pending_features.lock().map_err(|_| {
452                OptimError::EvaluationError("predictor feature buffer poisoned".into())
453            })?;
454            for result in results {
455                if !result.success {
456                    continue;
457                }
458                if pending.is_empty() {
459                    break;
460                }
461                let features = pending.remove(0);
462                let target = Self::clamp_unit(result.overall_score);
463                pairs.push((features, target));
464            }
465        }
466
467        let lr = self.learning_rate;
468        let lambda = self.ridge_lambda;
469
470        for (features, target) in pairs {
471            // Online ridge / logistic SGD step:
472            //   w <- w - lr * ((sigmoid(w . x) - y) * x + lambda * w)
473            let raw = self.raw_score(&features);
474            let prediction = Self::sigmoid(raw);
475            let error = prediction - target;
476
477            {
478                let weights = &mut self.predictor_model.parameters.weights[0];
479                for i in 0..FEATURE_DIM {
480                    let grad = error * features[i] + lambda * weights[[i, 0]];
481                    weights[[i, 0]] = weights[[i, 0]] - lr * grad;
482                }
483            }
484
485            // Track the loss for diagnostics / training state.
486            let loss = error * error;
487            self.predictor_model.training_state.loss_history.push(loss);
488            self.predictor_model.training_state.current_epoch += 1;
489
490            // Persist the training pair. It is what `training_rmse` and the
491            // observation-count-driven confidence interval are computed from.
492            self.training_data.architecture_features.push(features);
493            self.training_data.performance_targets.push(target);
494        }
495
496        Ok(())
497    }
498}
499
500impl<T: Float + Debug + Default + Send + Sync> PredictorModel<T> {
501    /// Build the linear (logistic-squashed) model this predictor uses: one
502    /// `[FEATURE_DIM, 1]` weight matrix, zero-initialised.
503    ///
504    /// The previous body claimed a `NeuralNetwork` model type and populated a
505    /// 64-128-64-1 layer stack, ReLU activations, dropout rates, an exponential
506    /// learning-rate schedule and an early-stopping state — none of which any code
507    /// path ever read; the model was, and is, linear.
508    fn new() -> Self {
509        Self {
510            model_type: PredictorModelType::LinearRegression,
511            parameters: ModelParameters {
512                weights: vec![Array2::zeros((FEATURE_DIM, 1))],
513            },
514            training_state: ModelTrainingState {
515                current_epoch: 0,
516                loss_history: Vec::new(),
517            },
518        }
519    }
520}
521
522impl<T: Float + Debug + Default + Send + Sync> PredictorTrainingData<T> {
523    fn new() -> Self {
524        Self {
525            architecture_features: Vec::new(),
526            performance_targets: Vec::new(),
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    /// Build a minimal evaluation configuration for the predictor.
536    fn make_config() -> EvaluationConfig {
537        EvaluationConfig {
538            epochs: 1,
539            batch_size: 8,
540            learning_rate: 0.01,
541            performance_prediction: true,
542        }
543    }
544
545    /// Build a deterministic optimizer architecture for testing.
546    fn make_architecture(id: &str) -> OptimizerArchitecture<f64> {
547        let mut parameters = HashMap::new();
548        parameters.insert("learning_rate".to_string(), 0.01_f64);
549        parameters.insert("momentum".to_string(), 0.9_f64);
550        parameters.insert("weight_decay".to_string(), 0.0001_f64);
551
552        OptimizerArchitecture {
553            architecture_id: id.to_string(),
554            parameters,
555            components: vec![
556                "AdamW".to_string(),
557                "CosineSchedule".to_string(),
558                "GradientClipping".to_string(),
559            ],
560            connections: Vec::new(),
561            metadata: HashMap::new(),
562            hyperparameters: HashMap::new(),
563        }
564    }
565
566    #[test]
567    fn test_feature_vector_is_fixed_length_and_deterministic() {
568        let predictor = PerformancePredictor::<f64>::new(&make_config())
569            .expect("predictor construction should succeed");
570        let architecture = make_architecture("arch-features");
571
572        let f1 = predictor.extract_features(&architecture);
573        let f2 = predictor.extract_features(&architecture);
574
575        assert_eq!(f1.len(), FEATURE_DIM);
576        // Determinism: identical architecture -> identical features.
577        for i in 0..FEATURE_DIM {
578            assert!((f1[i] - f2[i]).abs() < 1e-12);
579        }
580        // Bias/intercept feature is always 1.
581        assert!((f1[0] - 1.0).abs() < 1e-12);
582        // All features remain finite.
583        for i in 0..FEATURE_DIM {
584            assert!(f1[i].is_finite());
585        }
586    }
587
588    #[test]
589    fn test_predict_returns_valid_results() {
590        let predictor = PerformancePredictor::<f64>::new(&make_config())
591            .expect("predictor construction should succeed");
592        let architecture = make_architecture("arch-predict");
593
594        let results = predictor
595            .predict_performance(&architecture)
596            .expect("prediction should succeed");
597
598        // Score must be a valid probability in [0, 1].
599        assert!(results.overall_score >= 0.0 && results.overall_score <= 1.0);
600        assert!(results.success);
601        assert!(results.error_message.is_none());
602
603        // At least one metric score must be populated.
604        assert!(!results.metric_scores.is_empty());
605        let predicted = results
606            .metric_scores
607            .get(&EvaluationMetric::FinalPerformance)
608            .copied()
609            .expect("FinalPerformance metric should be present");
610        assert!((predicted - results.overall_score).abs() < 1e-12);
611
612        // A confidence interval must be present and well-formed within [0, 1].
613        let (lo, hi) = results
614            .confidence_intervals
615            .get(&EvaluationMetric::FinalPerformance)
616            .copied()
617            .expect("confidence interval should be present");
618        assert!(lo >= 0.0 && hi <= 1.0);
619        assert!(lo <= hi);
620
621        // Empty optional collections are valid initial state.
622        assert!(results.cv_results.is_none());
623        assert!(results.benchmark_results.is_empty());
624        assert!(results.training_trajectory.is_empty());
625    }
626
627    #[test]
628    fn test_confidence_interval_narrows_with_more_data() {
629        let mut predictor = PerformancePredictor::<f64>::new(&make_config())
630            .expect("predictor construction should succeed");
631        let architecture = make_architecture("arch-uncertainty");
632
633        let wide = predictor.confidence_half_width();
634
635        // Feed several observations to accumulate samples.
636        for _ in 0..25 {
637            let _ = predictor
638                .predict_performance(&architecture)
639                .expect("prediction should succeed");
640            let result = predictor
641                .predict_performance(&architecture)
642                .expect("prediction should succeed");
643            predictor
644                .update_with_results(std::slice::from_ref(&result))
645                .expect("update should succeed");
646        }
647
648        let narrow = predictor.confidence_half_width();
649        // More observed data must reduce epistemic uncertainty.
650        assert!(narrow < wide);
651    }
652
653    #[test]
654    fn test_model_learns_toward_high_target() {
655        let mut predictor = PerformancePredictor::<f64>::new(&make_config())
656            .expect("predictor construction should succeed");
657        let architecture = make_architecture("arch-learn");
658
659        // Baseline prediction before any training.
660        let baseline = predictor
661            .predict_performance(&architecture)
662            .expect("prediction should succeed")
663            .overall_score;
664
665        let high_target = 0.95_f64;
666
667        // Repeatedly present the SAME architecture with a consistently high target.
668        // Each iteration: predict (buffers the architecture features), then update
669        // with a synthetic result carrying the high target so the online ridge step
670        // forms a genuine (features -> target) pair.
671        for _ in 0..200 {
672            let mut result = predictor
673                .predict_performance(&architecture)
674                .expect("prediction should succeed");
675            result.overall_score = high_target;
676            predictor
677                .update_with_results(std::slice::from_ref(&result))
678                .expect("update should succeed");
679        }
680
681        let learned = predictor
682            .predict_performance(&architecture)
683            .expect("prediction should succeed")
684            .overall_score;
685
686        // Learning must move the prediction upward toward the target.
687        assert!(
688            learned > baseline + 0.05,
689            "prediction did not increase: baseline={baseline}, learned={learned}"
690        );
691        // And it should approach (without necessarily reaching) the target, within
692        // a generous tolerance for the squashed online learner.
693        assert!(
694            (high_target - learned).abs() < 0.25,
695            "prediction did not converge toward target: learned={learned}, target={high_target}"
696        );
697        // The learner must have recorded the training pairs it consumed.
698        assert!(predictor.num_observations() >= 200);
699    }
700
701    #[test]
702    fn test_update_skips_failed_results() {
703        let mut predictor = PerformancePredictor::<f64>::new(&make_config())
704            .expect("predictor construction should succeed");
705        let architecture = make_architecture("arch-failed");
706
707        let mut result = predictor
708            .predict_performance(&architecture)
709            .expect("prediction should succeed");
710        result.success = false;
711        result.error_message = Some("synthetic failure".to_string());
712        result.overall_score = 0.99;
713
714        predictor
715            .update_with_results(std::slice::from_ref(&result))
716            .expect("update should succeed");
717
718        // Failed evaluations must not contribute training pairs.
719        assert_eq!(predictor.num_observations(), 0);
720    }
721
722    #[test]
723    fn test_recorded_training_state_is_readable_and_consistent() {
724        let mut predictor = PerformancePredictor::<f64>::new(&make_config())
725            .expect("predictor construction should succeed");
726        let architecture = make_architecture("arch-state");
727
728        // A fresh predictor has trained on nothing, and says so instead of
729        // reporting a fabricated error.
730        assert_eq!(
731            predictor.model_type(),
732            &PredictorModelType::LinearRegression
733        );
734        assert_eq!(predictor.epochs_trained(), 0);
735        assert!(predictor.training_loss_history().is_empty());
736        assert_eq!(predictor.observation_count(), 0);
737        assert!(predictor.training_rmse().is_none());
738
739        for _ in 0..40 {
740            let mut result = predictor
741                .predict_performance(&architecture)
742                .expect("prediction should succeed");
743            result.overall_score = 0.9;
744            predictor
745                .update_with_results(std::slice::from_ref(&result))
746                .expect("update should succeed");
747        }
748
749        // Every applied update is counted once and leaves exactly one loss entry.
750        assert_eq!(predictor.epochs_trained(), 40);
751        assert_eq!(predictor.training_loss_history().len(), 40);
752        assert_eq!(predictor.observation_count(), 40);
753
754        let rmse = predictor
755            .training_rmse()
756            .expect("training RMSE is defined once pairs exist");
757        assert!(rmse.is_finite());
758        assert!((0.0..=1.0).contains(&rmse));
759        // Fitting one repeated target must leave the model closer to it than a
760        // zero-weight model was (sigmoid(0) = 0.5, so the initial error is 0.4).
761        assert!(rmse < 0.4, "training did not reduce the fit error: {rmse}");
762    }
763
764    #[test]
765    fn test_confidence_level_is_validated_and_widens_the_interval() {
766        let mut predictor = PerformancePredictor::<f64>::new(&make_config())
767            .expect("predictor construction should succeed");
768
769        assert!((predictor.confidence_level() - 0.95).abs() < 1e-12);
770
771        let wide = predictor.confidence_half_width();
772        predictor
773            .set_confidence_level(0.5)
774            .expect("0.5 is a valid confidence level");
775        let narrow = predictor.confidence_half_width();
776        assert!(
777            narrow < wide,
778            "a lower confidence level must not widen the interval: {narrow} vs {wide}"
779        );
780
781        // Degenerate levels are rejected rather than silently clamped.
782        for bad in [0.0, 1.0, -0.5, 1.5, f64::NAN] {
783            assert!(
784                predictor.set_confidence_level(bad).is_err(),
785                "confidence level {bad} must be rejected"
786            );
787        }
788        // A rejected value must not have been stored.
789        assert!((predictor.confidence_level() - 0.5).abs() < 1e-12);
790    }
791}