Skip to main content

optirs_learned/adaptive/
predictor.rs

1//! Real performance prediction for the adaptive transformer enhancement.
2//!
3//! `TransformerPerformancePredictor::predict_improvement` used to return the
4//! constants `0.15 / 0.92 / 0.85 / 0.05` for every input, and its
5//! `PredictorNetwork` was zero-initialized (so wiring it up as-is would have
6//! output `0` forever). This module replaces both with a working predictor:
7//!
8//! 1. **Features** — [`PredictionFeatures`] turns a landscape analysis plus a
9//!    proposed architecture adaptation into a fixed-length real feature vector.
10//! 2. **Random feature map** — [`PredictorNetwork`] keeps Xavier-initialized
11//!    hidden layers with `tanh` nonlinearities. They are *fixed* (a random
12//!    feature expansion, as in random-feature / extreme-learning-machine
13//!    regression), so the model is linear in its trainable parameters.
14//! 3. **Learned heads** — the output layer is fitted by ridge regression with a
15//!    closed-form normal-equation solve (Cholesky). Two heads are learned:
16//!    convergence improvement and final performance.
17//! 4. **Uncertainty** — the ridge posterior gives a genuine predictive standard
18//!    deviation `σ·sqrt(1 + φᵗA⁻¹φ)`; confidence is derived from it. Before any
19//!    training the predictor reports maximum uncertainty and zero confidence
20//!    instead of a fabricated `0.85`.
21//!
22//! Everything is pure Rust over `scirs2_core::ndarray`.
23
24use scirs2_core::ndarray::{Array1, Array2};
25use scirs2_core::numeric::Float;
26use std::collections::HashMap;
27use std::fmt::Debug;
28
29use crate::error::{OptimError, Result};
30
31/// Number of real features the predictor consumes.
32pub const PREDICTION_FEATURE_COUNT: usize = 16;
33
34/// Feature vector handed to [`PredictorNetwork`].
35///
36/// Field order is the vector order; see [`Self::to_vec`].
37#[derive(Debug, Clone, PartialEq)]
38pub struct PredictionFeatures {
39    /// Landscape complexity in `[0, 1]`.
40    pub complexity: f64,
41    /// Landscape difficulty in `[0, 1]`.
42    pub difficulty: f64,
43    /// Confidence attached to the landscape analysis, `[0, 1]`.
44    pub landscape_confidence: f64,
45    /// Improvement the architecture adapter expects.
46    pub expected_improvement: f64,
47    /// Confidence the architecture adapter reports.
48    pub adaptation_confidence: f64,
49    /// Number of architecture changes, normalized by a soft cap of 8.
50    pub change_count: f64,
51    /// Proposed layer count, normalized by a soft cap of 24.
52    pub layer_count: f64,
53    /// Proposed hidden width, normalized by a soft cap of 2048.
54    pub hidden_size: f64,
55    /// Proposed attention-head count, normalized by a soft cap of 32.
56    pub head_count: f64,
57    /// Proposed dropout rate.
58    pub dropout: f64,
59    /// `complexity · difficulty` interaction.
60    pub interaction: f64,
61    /// `complexity²`.
62    pub complexity_sq: f64,
63    /// `difficulty²`.
64    pub difficulty_sq: f64,
65    /// One-hot-ish encoding of the recommended strategy family (3 slots):
66    /// conservative, aggressive, exploratory.
67    pub strategy_conservative: f64,
68    /// See [`Self::strategy_conservative`].
69    pub strategy_aggressive: f64,
70    /// See [`Self::strategy_conservative`].
71    pub strategy_exploratory: f64,
72}
73
74impl PredictionFeatures {
75    /// Flatten into the fixed-length vector the network expects.
76    pub fn to_vec<T>(&self) -> Array1<T>
77    where
78        T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
79    {
80        let raw = [
81            self.complexity,
82            self.difficulty,
83            self.landscape_confidence,
84            self.expected_improvement,
85            self.adaptation_confidence,
86            self.change_count,
87            self.layer_count,
88            self.hidden_size,
89            self.head_count,
90            self.dropout,
91            self.interaction,
92            self.complexity_sq,
93            self.difficulty_sq,
94            self.strategy_conservative,
95            self.strategy_aggressive,
96            self.strategy_exploratory,
97        ];
98        debug_assert_eq!(raw.len(), PREDICTION_FEATURE_COUNT);
99        Array1::from_shape_fn(PREDICTION_FEATURE_COUNT, |i| {
100            scirs2_core::numeric::NumCast::from(raw[i]).unwrap_or_else(|| T::zero())
101        })
102    }
103
104    /// Stable cache key. Quantized to 4 decimals so numerically identical
105    /// requests hit the cache while genuinely different ones do not collide.
106    pub fn cache_key(&self) -> String {
107        let raw = [
108            self.complexity,
109            self.difficulty,
110            self.landscape_confidence,
111            self.expected_improvement,
112            self.adaptation_confidence,
113            self.change_count,
114            self.layer_count,
115            self.hidden_size,
116            self.head_count,
117            self.dropout,
118            self.strategy_conservative,
119            self.strategy_aggressive,
120            self.strategy_exploratory,
121        ];
122        let mut key = String::with_capacity(raw.len() * 8);
123        for v in raw {
124            key.push_str(&format!("{:.4}|", v));
125        }
126        key
127    }
128}
129
130/// One `(features, targets)` training pair.
131#[derive(Debug, Clone)]
132pub struct PredictorSample {
133    /// Input features.
134    pub features: PredictionFeatures,
135    /// Observed convergence improvement.
136    pub convergence_improvement: f64,
137    /// Observed final performance.
138    pub final_performance: f64,
139}
140
141/// Result of a prediction: two means plus a real uncertainty.
142#[derive(Debug, Clone, PartialEq)]
143pub struct PredictorOutput {
144    /// Predicted convergence improvement.
145    pub convergence_improvement: f64,
146    /// Predicted final performance.
147    pub final_performance: f64,
148    /// Predictive standard deviation shared by both heads (the larger of the two).
149    pub uncertainty: f64,
150    /// `1 / (1 + uncertainty)` — monotonically decreasing in uncertainty, and
151    /// exactly `0` while the predictor is untrained.
152    pub confidence: f64,
153}
154
155/// Fitted-model diagnostics returned by [`PredictorNetwork::fit`].
156#[derive(Debug, Clone, PartialEq)]
157pub struct PredictorFitReport {
158    /// Root-mean-square error of the convergence head on the training set.
159    pub convergence_rmse: f64,
160    /// Root-mean-square error of the performance head on the training set.
161    pub performance_rmse: f64,
162    /// Number of samples the fit used.
163    pub samples: usize,
164}
165
166/// Performance prediction network: fixed random feature map + ridge-fitted heads.
167#[derive(Debug, Clone)]
168pub struct PredictorNetwork<
169    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
170> {
171    /// Fixed hidden layers (Xavier-initialized, never zero).
172    hidden_weights: Vec<Array2<T>>,
173    /// Hidden biases.
174    hidden_biases: Vec<Array1<T>>,
175    /// Learned head for convergence improvement (length `feature_width + 1`).
176    convergence_head: Array1<T>,
177    /// Learned head for final performance.
178    performance_head: Array1<T>,
179    /// `(ΦᵗΦ + λI)⁻¹` in augmented feature space, kept for predictive variance.
180    posterior_precision_inv: Option<Array2<T>>,
181    /// Residual standard deviation of the fit.
182    residual_std: T,
183    /// Ridge coefficient.
184    ridge_lambda: T,
185    /// Layer widths, `[input, hidden..., feature_width]`.
186    architecture: Vec<usize>,
187    /// Whether [`Self::fit`] has succeeded at least once.
188    trained: bool,
189}
190
191impl<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>
192    PredictorNetwork<T>
193{
194    /// Build the network.
195    ///
196    /// `architecture` lists the widths of the input layer and every hidden
197    /// layer; the last entry is the width of the feature map the linear heads
198    /// read. `ridge_lambda` must be positive so the normal equations stay
199    /// positive-definite (and hence Cholesky-solvable) even with fewer samples
200    /// than features.
201    ///
202    /// # Errors
203    /// Returns `Err` when the architecture has fewer than two layers, any width
204    /// is zero, the input width disagrees with [`PREDICTION_FEATURE_COUNT`], or
205    /// `ridge_lambda <= 0`.
206    pub fn new(architecture: Vec<usize>, ridge_lambda: T) -> Result<Self> {
207        if architecture.len() < 2 {
208            return Err(OptimError::InvalidConfig(
209                "predictor architecture needs an input and at least one hidden layer".to_string(),
210            ));
211        }
212        if architecture.contains(&0) {
213            return Err(OptimError::InvalidConfig(
214                "predictor layer widths must be positive".to_string(),
215            ));
216        }
217        if architecture[0] != PREDICTION_FEATURE_COUNT {
218            return Err(OptimError::InvalidConfig(format!(
219                "predictor input width {} must equal PREDICTION_FEATURE_COUNT ({})",
220                architecture[0], PREDICTION_FEATURE_COUNT
221            )));
222        }
223        if ridge_lambda <= T::zero() {
224            return Err(OptimError::InvalidConfig(
225                "ridge_lambda must be positive".to_string(),
226            ));
227        }
228
229        let mut hidden_weights = Vec::with_capacity(architecture.len() - 1);
230        let mut hidden_biases = Vec::with_capacity(architecture.len() - 1);
231        let mut rng = scirs2_core::random::thread_rng();
232        for i in 0..architecture.len() - 1 {
233            let fan_in = architecture[i];
234            let fan_out = architecture[i + 1];
235            // Xavier/Glorot uniform: limit sqrt(6 / (fan_in + fan_out)).
236            let bound = (6.0 / (fan_in + fan_out) as f64).sqrt();
237            let weight = Array2::from_shape_fn((fan_out, fan_in), |_| {
238                let sample = rng.random::<f64>() * 2.0 - 1.0;
239                scirs2_core::numeric::NumCast::from(sample * bound).unwrap_or_else(|| T::zero())
240            });
241            hidden_weights.push(weight);
242            hidden_biases.push(Array1::zeros(fan_out));
243        }
244
245        let feature_width = architecture[architecture.len() - 1];
246        Ok(Self {
247            hidden_weights,
248            hidden_biases,
249            // Untrained heads are zero on purpose: an untrained predictor must
250            // say "I predict nothing, with maximum uncertainty", not invent a
251            // number. `trained` gates that.
252            convergence_head: Array1::zeros(feature_width + 1),
253            performance_head: Array1::zeros(feature_width + 1),
254            posterior_precision_inv: None,
255            residual_std: T::zero(),
256            ridge_lambda,
257            architecture,
258            trained: false,
259        })
260    }
261
262    /// Whether the heads have been fitted.
263    pub fn is_trained(&self) -> bool {
264        self.trained
265    }
266
267    /// Layer widths.
268    pub fn architecture(&self) -> &[usize] {
269        &self.architecture
270    }
271
272    /// Width of the feature map the heads read.
273    pub fn feature_width(&self) -> usize {
274        self.architecture[self.architecture.len() - 1]
275    }
276
277    /// Total parameter count (fixed map + learned heads).
278    pub fn parameter_count(&self) -> usize {
279        let hidden: usize = self.hidden_weights.iter().map(|w| w.len()).sum::<usize>()
280            + self.hidden_biases.iter().map(|b| b.len()).sum::<usize>();
281        hidden + self.convergence_head.len() + self.performance_head.len()
282    }
283
284    /// Random feature map `φ(x)`, augmented with a trailing 1 for the bias.
285    fn feature_map(&self, input: &Array1<T>) -> Result<Array1<T>> {
286        if input.len() != self.architecture[0] {
287            return Err(OptimError::InvalidConfig(format!(
288                "predictor expected {} features, got {}",
289                self.architecture[0],
290                input.len()
291            )));
292        }
293        let mut activation = input.clone();
294        for (weight, bias) in self.hidden_weights.iter().zip(self.hidden_biases.iter()) {
295            let pre = weight.dot(&activation) + bias;
296            // tanh keeps the map bounded, so the ridge solve is well-scaled
297            // regardless of how large the raw features are.
298            activation = pre.mapv(|x| x.tanh());
299        }
300
301        let mut augmented = Array1::zeros(activation.len() + 1);
302        for (i, &v) in activation.iter().enumerate() {
303            augmented[i] = v;
304        }
305        augmented[activation.len()] = T::one();
306        Ok(augmented)
307    }
308
309    /// Fit both heads by ridge regression on `samples`.
310    ///
311    /// Solves `(ΦᵗΦ + λI) w = Φᵗ y` exactly via Cholesky. Returns the training
312    /// RMSE of each head.
313    ///
314    /// # Errors
315    /// Returns `Err` when `samples` is empty or the normal-equation matrix is
316    /// not positive-definite (which cannot happen for `λ > 0`, but is checked
317    /// rather than assumed).
318    pub fn fit(&mut self, samples: &[PredictorSample]) -> Result<PredictorFitReport> {
319        if samples.is_empty() {
320            return Err(OptimError::InsufficientData(
321                "predictor training needs at least one sample".to_string(),
322            ));
323        }
324
325        let width = self.feature_width() + 1;
326        let mut design: Vec<Array1<T>> = Vec::with_capacity(samples.len());
327        for sample in samples {
328            design.push(self.feature_map(&sample.features.to_vec::<T>())?);
329        }
330
331        // Normal equations.
332        let mut gram = Array2::<T>::zeros((width, width));
333        let mut rhs_conv = Array1::<T>::zeros(width);
334        let mut rhs_perf = Array1::<T>::zeros(width);
335        for (phi, sample) in design.iter().zip(samples.iter()) {
336            let y_c: T = scirs2_core::numeric::NumCast::from(sample.convergence_improvement)
337                .unwrap_or_else(|| T::zero());
338            let y_p: T = scirs2_core::numeric::NumCast::from(sample.final_performance)
339                .unwrap_or_else(|| T::zero());
340            for i in 0..width {
341                rhs_conv[i] = rhs_conv[i] + phi[i] * y_c;
342                rhs_perf[i] = rhs_perf[i] + phi[i] * y_p;
343                for j in 0..width {
344                    gram[[i, j]] = gram[[i, j]] + phi[i] * phi[j];
345                }
346            }
347        }
348        for i in 0..width {
349            gram[[i, i]] = gram[[i, i]] + self.ridge_lambda;
350        }
351
352        let chol = cholesky(&gram)?;
353        self.convergence_head = cholesky_solve(&chol, &rhs_conv);
354        self.performance_head = cholesky_solve(&chol, &rhs_perf);
355        self.posterior_precision_inv = Some(cholesky_inverse(&chol));
356
357        // Residual statistics (pooled across both heads).
358        let mut sq_conv = T::zero();
359        let mut sq_perf = T::zero();
360        for (phi, sample) in design.iter().zip(samples.iter()) {
361            let pred_c = dot(&self.convergence_head, phi);
362            let pred_p = dot(&self.performance_head, phi);
363            let y_c: T = scirs2_core::numeric::NumCast::from(sample.convergence_improvement)
364                .unwrap_or_else(|| T::zero());
365            let y_p: T = scirs2_core::numeric::NumCast::from(sample.final_performance)
366                .unwrap_or_else(|| T::zero());
367            sq_conv = sq_conv + (pred_c - y_c) * (pred_c - y_c);
368            sq_perf = sq_perf + (pred_p - y_p) * (pred_p - y_p);
369        }
370        let n = T::from(samples.len()).unwrap_or_else(|| T::one());
371        let rmse_conv = (sq_conv / n).sqrt();
372        let rmse_perf = (sq_perf / n).sqrt();
373        self.residual_std = if rmse_conv > rmse_perf {
374            rmse_conv
375        } else {
376            rmse_perf
377        };
378        self.trained = true;
379
380        Ok(PredictorFitReport {
381            convergence_rmse: rmse_conv.to_f64().unwrap_or(0.0),
382            performance_rmse: rmse_perf.to_f64().unwrap_or(0.0),
383            samples: samples.len(),
384        })
385    }
386
387    /// Predict from a feature vector.
388    ///
389    /// While untrained the means are `0` and the uncertainty is `1` with
390    /// confidence `0` — an explicit "no information" answer rather than a
391    /// fabricated constant.
392    pub fn predict(&self, features: &PredictionFeatures) -> Result<PredictorOutput> {
393        let phi = self.feature_map(&features.to_vec::<T>())?;
394        if !self.trained {
395            return Ok(PredictorOutput {
396                convergence_improvement: 0.0,
397                final_performance: 0.0,
398                uncertainty: 1.0,
399                confidence: 0.0,
400            });
401        }
402
403        let mean_c = dot(&self.convergence_head, &phi).to_f64().unwrap_or(0.0);
404        let mean_p = dot(&self.performance_head, &phi).to_f64().unwrap_or(0.0);
405
406        // Ridge predictive variance: σ²·(1 + φᵗ A⁻¹ φ).
407        let leverage = match &self.posterior_precision_inv {
408            Some(a_inv) => {
409                let av = a_inv.dot(&phi);
410                dot(&av, &phi).to_f64().unwrap_or(0.0).max(0.0)
411            }
412            None => 0.0,
413        };
414        let sigma = self.residual_std.to_f64().unwrap_or(0.0).abs();
415        let uncertainty = sigma * (1.0 + leverage).sqrt();
416
417        Ok(PredictorOutput {
418            convergence_improvement: mean_c,
419            final_performance: mean_p,
420            uncertainty,
421            confidence: 1.0 / (1.0 + uncertainty),
422        })
423    }
424}
425
426/// Bounded prediction cache with real hit-rate accounting.
427///
428/// The previous version stored a `HashMap`, a `capacity` and a `hit_rate` and
429/// never read or updated any of them.
430#[derive(Debug, Clone)]
431pub struct PredictionCache {
432    entries: HashMap<String, PredictorOutput>,
433    /// Insertion order, for FIFO eviction at capacity.
434    order: Vec<String>,
435    capacity: usize,
436    hits: u64,
437    misses: u64,
438}
439
440impl PredictionCache {
441    /// Create a cache holding at most `capacity` entries (`capacity == 0`
442    /// disables caching entirely).
443    pub fn new(capacity: usize) -> Self {
444        Self {
445            entries: HashMap::new(),
446            order: Vec::new(),
447            capacity,
448            hits: 0,
449            misses: 0,
450        }
451    }
452
453    /// Look up a prediction, updating the hit/miss counters.
454    pub fn get(&mut self, key: &str) -> Option<PredictorOutput> {
455        match self.entries.get(key) {
456            Some(v) => {
457                self.hits += 1;
458                Some(v.clone())
459            }
460            None => {
461                self.misses += 1;
462                None
463            }
464        }
465    }
466
467    /// Insert a prediction, evicting the oldest entry at capacity.
468    pub fn insert(&mut self, key: String, value: PredictorOutput) {
469        if self.capacity == 0 {
470            return;
471        }
472        if self.entries.insert(key.clone(), value).is_none() {
473            self.order.push(key);
474            while self.order.len() > self.capacity {
475                let evicted = self.order.remove(0);
476                self.entries.remove(&evicted);
477            }
478        }
479    }
480
481    /// Observed hit rate, or `0` before any lookup.
482    pub fn hit_rate(&self) -> f64 {
483        let total = self.hits + self.misses;
484        if total == 0 {
485            0.0
486        } else {
487            self.hits as f64 / total as f64
488        }
489    }
490
491    /// Number of cached entries.
492    pub fn len(&self) -> usize {
493        self.entries.len()
494    }
495
496    /// Whether the cache holds nothing.
497    pub fn is_empty(&self) -> bool {
498        self.entries.is_empty()
499    }
500
501    /// Drop every entry (counters are preserved).
502    pub fn clear(&mut self) {
503        self.entries.clear();
504        self.order.clear();
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Small dense linear algebra (pure Rust, no BLAS)
510// ---------------------------------------------------------------------------
511
512fn dot<T: Float>(a: &Array1<T>, b: &Array1<T>) -> T {
513    let n = a.len().min(b.len());
514    let mut acc = T::zero();
515    for i in 0..n {
516        acc = acc + a[i] * b[i];
517    }
518    acc
519}
520
521/// Lower-triangular Cholesky factor `L` with `A = L Lᵗ`.
522///
523/// # Errors
524/// Returns `Err` when `A` is not symmetric positive-definite.
525fn cholesky<T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static>(
526    a: &Array2<T>,
527) -> Result<Array2<T>> {
528    let n = a.nrows();
529    if a.ncols() != n {
530        return Err(OptimError::InvalidConfig(
531            "Cholesky requires a square matrix".to_string(),
532        ));
533    }
534    let mut l = Array2::<T>::zeros((n, n));
535    for i in 0..n {
536        for j in 0..=i {
537            let mut sum = a[[i, j]];
538            for k in 0..j {
539                sum = sum - l[[i, k]] * l[[j, k]];
540            }
541            if i == j {
542                if sum <= T::zero() {
543                    return Err(OptimError::ComputationError(format!(
544                        "matrix is not positive-definite at pivot {i}"
545                    )));
546                }
547                l[[i, j]] = sum.sqrt();
548            } else {
549                l[[i, j]] = sum / l[[j, j]];
550            }
551        }
552    }
553    Ok(l)
554}
555
556/// Solve `L Lᵗ x = b` given the Cholesky factor `L`.
557fn cholesky_solve<
558    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
559>(
560    l: &Array2<T>,
561    b: &Array1<T>,
562) -> Array1<T> {
563    let n = l.nrows();
564    // Forward substitution: L y = b
565    let mut y = Array1::<T>::zeros(n);
566    for i in 0..n {
567        let mut sum = b[i];
568        for k in 0..i {
569            sum = sum - l[[i, k]] * y[k];
570        }
571        y[i] = sum / l[[i, i]];
572    }
573    // Back substitution: Lᵗ x = y
574    let mut x = Array1::<T>::zeros(n);
575    for i in (0..n).rev() {
576        let mut sum = y[i];
577        for k in (i + 1)..n {
578            sum = sum - l[[k, i]] * x[k];
579        }
580        x[i] = sum / l[[i, i]];
581    }
582    x
583}
584
585/// `(L Lᵗ)⁻¹`, computed column by column.
586fn cholesky_inverse<
587    T: Float + Debug + scirs2_core::ndarray::ScalarOperand + Send + Sync + 'static,
588>(
589    l: &Array2<T>,
590) -> Array2<T> {
591    let n = l.nrows();
592    let mut inv = Array2::<T>::zeros((n, n));
593    let mut unit = Array1::<T>::zeros(n);
594    for col in 0..n {
595        unit.fill(T::zero());
596        unit[col] = T::one();
597        let solution = cholesky_solve(l, &unit);
598        for row in 0..n {
599            inv[[row, col]] = solution[row];
600        }
601    }
602    inv
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn features(complexity: f64, difficulty: f64) -> PredictionFeatures {
610        PredictionFeatures {
611            complexity,
612            difficulty,
613            landscape_confidence: 0.8,
614            expected_improvement: 0.1,
615            adaptation_confidence: 0.7,
616            change_count: 0.125,
617            layer_count: 6.0 / 24.0,
618            hidden_size: 512.0 / 2048.0,
619            head_count: 8.0 / 32.0,
620            dropout: 0.1,
621            interaction: complexity * difficulty,
622            complexity_sq: complexity * complexity,
623            difficulty_sq: difficulty * difficulty,
624            strategy_conservative: 0.0,
625            strategy_aggressive: 0.0,
626            strategy_exploratory: 1.0,
627        }
628    }
629
630    fn network() -> PredictorNetwork<f64> {
631        PredictorNetwork::<f64>::new(vec![PREDICTION_FEATURE_COUNT, 32, 24], 1e-4)
632            .expect("network construction")
633    }
634
635    /// Synthetic ground truth: improvement falls with difficulty and rises with
636    /// (low) complexity; final performance is the complement.
637    fn truth(f: &PredictionFeatures) -> (f64, f64) {
638        let improvement = 0.30 - 0.20 * f.difficulty - 0.05 * f.complexity;
639        let performance = 0.95 - 0.30 * f.difficulty - 0.10 * f.complexity;
640        (improvement, performance)
641    }
642
643    fn training_set(n: usize) -> Vec<PredictorSample> {
644        (0..n)
645            .map(|i| {
646                let c = (i as f64 * 0.037).fract();
647                let d = (i as f64 * 0.091).fract();
648                let f = features(c, d);
649                let (ci, fp) = truth(&f);
650                PredictorSample {
651                    features: f,
652                    convergence_improvement: ci,
653                    final_performance: fp,
654                }
655            })
656            .collect()
657    }
658
659    #[test]
660    fn rejects_bad_architecture() {
661        assert!(PredictorNetwork::<f64>::new(vec![PREDICTION_FEATURE_COUNT], 1e-4).is_err());
662        assert!(PredictorNetwork::<f64>::new(vec![3, 8], 1e-4).is_err());
663        assert!(PredictorNetwork::<f64>::new(vec![PREDICTION_FEATURE_COUNT, 0], 1e-4).is_err());
664        assert!(PredictorNetwork::<f64>::new(vec![PREDICTION_FEATURE_COUNT, 8], 0.0).is_err());
665    }
666
667    /// The old `PredictorNetwork::new` used `Array2::zeros`, so the feature map
668    /// output `tanh(0) = 0` for every input.
669    #[test]
670    fn hidden_layers_are_not_zero_initialized() {
671        let net = network();
672        let phi_a = net
673            .feature_map(&features(0.1, 0.9).to_vec::<f64>())
674            .expect("map");
675        let phi_b = net
676            .feature_map(&features(0.9, 0.1).to_vec::<f64>())
677            .expect("map");
678        let magnitude = phi_a.iter().map(|v| v.abs()).fold(0.0, f64::max);
679        assert!(
680            magnitude > 1e-6,
681            "feature map collapsed to zero (max |phi| {magnitude})"
682        );
683        let delta = phi_a
684            .iter()
685            .zip(phi_b.iter())
686            .map(|(a, b)| (a - b).abs())
687            .fold(0.0, f64::max);
688        assert!(
689            delta > 1e-6,
690            "feature map ignores its input (delta {delta})"
691        );
692    }
693
694    #[test]
695    fn untrained_predictor_reports_no_information() {
696        let net = network();
697        let out = net.predict(&features(0.5, 0.5)).expect("predict");
698        assert_eq!(out.convergence_improvement, 0.0);
699        assert_eq!(out.confidence, 0.0);
700        assert_eq!(out.uncertainty, 1.0);
701    }
702
703    /// The headline requirement: different inputs must give different outputs.
704    #[test]
705    fn different_inputs_give_different_predictions() {
706        let mut net = network();
707        // A dense training set so the ridge fit generalizes reliably regardless of
708        // the random feature map's draw; the ordering assertion below is only
709        // meaningful if the fit is actually good.
710        net.fit(&training_set(200)).expect("fit");
711
712        let easy = net.predict(&features(0.05, 0.05)).expect("predict easy");
713        let hard = net.predict(&features(0.95, 0.95)).expect("predict hard");
714
715        assert!(
716            (easy.convergence_improvement - hard.convergence_improvement).abs() > 1e-4,
717            "convergence prediction is constant: {} vs {}",
718            easy.convergence_improvement,
719            hard.convergence_improvement
720        );
721        assert!(
722            (easy.final_performance - hard.final_performance).abs() > 1e-4,
723            "performance prediction is constant: {} vs {}",
724            easy.final_performance,
725            hard.final_performance
726        );
727        // And the direction has to be right: an easier landscape must predict a
728        // larger improvement.
729        assert!(
730            easy.convergence_improvement > hard.convergence_improvement,
731            "easy {} should beat hard {}",
732            easy.convergence_improvement,
733            hard.convergence_improvement
734        );
735    }
736
737    /// The second headline requirement: training must reduce prediction error.
738    #[test]
739    fn training_reduces_held_out_prediction_error() {
740        let mut net = network();
741        let train = training_set(90);
742        let held_out: Vec<PredictorSample> = (0..25)
743            .map(|i| {
744                let c = ((i as f64 + 0.5) * 0.061).fract();
745                let d = ((i as f64 + 0.5) * 0.113).fract();
746                let f = features(c, d);
747                let (ci, fp) = truth(&f);
748                PredictorSample {
749                    features: f,
750                    convergence_improvement: ci,
751                    final_performance: fp,
752                }
753            })
754            .collect();
755
756        let error = |net: &PredictorNetwork<f64>| -> f64 {
757            let mut acc = 0.0;
758            for s in &held_out {
759                let p = net.predict(&s.features).expect("predict");
760                acc += (p.convergence_improvement - s.convergence_improvement).powi(2);
761                acc += (p.final_performance - s.final_performance).powi(2);
762            }
763            (acc / (2.0 * held_out.len() as f64)).sqrt()
764        };
765
766        let before = error(&net);
767        let report = net.fit(&train).expect("fit");
768        let after = error(&net);
769
770        assert_eq!(report.samples, train.len());
771        assert!(
772            after < before,
773            "training did not reduce held-out RMSE: {before} -> {after}"
774        );
775        assert!(
776            after < 0.02,
777            "held-out RMSE {after} is too large for a learnable target"
778        );
779        assert!(
780            report.convergence_rmse < 0.02,
781            "train RMSE {}",
782            report.convergence_rmse
783        );
784    }
785
786    #[test]
787    fn training_produces_real_uncertainty_and_confidence() {
788        let mut net = network();
789        net.fit(&training_set(80)).expect("fit");
790        let out = net.predict(&features(0.4, 0.6)).expect("predict");
791        assert!(out.uncertainty.is_finite() && out.uncertainty >= 0.0);
792        assert!(out.confidence > 0.0 && out.confidence <= 1.0);
793        // Confidence must be the documented function of uncertainty, not 0.85.
794        assert!((out.confidence - 1.0 / (1.0 + out.uncertainty)).abs() < 1e-12);
795    }
796
797    #[test]
798    fn fit_rejects_an_empty_training_set() {
799        let mut net = network();
800        assert!(net.fit(&[]).is_err());
801    }
802
803    #[test]
804    fn feature_vector_has_the_declared_width() {
805        assert_eq!(
806            features(0.3, 0.4).to_vec::<f64>().len(),
807            PREDICTION_FEATURE_COUNT
808        );
809    }
810
811    #[test]
812    fn cache_tracks_hits_and_evicts_at_capacity() {
813        let mut cache = PredictionCache::new(2);
814        let out = PredictorOutput {
815            convergence_improvement: 0.1,
816            final_performance: 0.9,
817            uncertainty: 0.05,
818            confidence: 0.95,
819        };
820        assert!(cache.get("a").is_none());
821        cache.insert("a".to_string(), out.clone());
822        cache.insert("b".to_string(), out.clone());
823        assert_eq!(cache.len(), 2);
824        cache.insert("c".to_string(), out);
825        assert_eq!(cache.len(), 2, "capacity was not enforced");
826        assert!(cache.get("a").is_none(), "oldest entry should be evicted");
827        assert!(cache.get("c").is_some());
828        assert!(cache.hit_rate() > 0.0 && cache.hit_rate() < 1.0);
829    }
830
831    #[test]
832    fn zero_capacity_cache_stores_nothing() {
833        let mut cache = PredictionCache::new(0);
834        cache.insert(
835            "a".to_string(),
836            PredictorOutput {
837                convergence_improvement: 0.0,
838                final_performance: 0.0,
839                uncertainty: 0.0,
840                confidence: 1.0,
841            },
842        );
843        assert!(cache.is_empty());
844    }
845
846    #[test]
847    fn cholesky_solves_a_known_system() {
848        // A = [[4, 2], [2, 3]], b = [2, 1] -> x = [0.25, 0.1666...]
849        let a = Array2::from_shape_vec((2, 2), vec![4.0, 2.0, 2.0, 3.0]).expect("shape");
850        let b = Array1::from_vec(vec![2.0, 1.0]);
851        let l = cholesky(&a).expect("factor");
852        let x = cholesky_solve(&l, &b);
853        let residual = a.dot(&x) - &b;
854        let err = residual.iter().map(|v| v.abs()).fold(0.0, f64::max);
855        assert!(err < 1e-12, "residual {err}");
856
857        let inv = cholesky_inverse(&l);
858        let identity = a.dot(&inv);
859        for i in 0..2 {
860            for j in 0..2 {
861                let expected = if i == j { 1.0 } else { 0.0 };
862                assert!((identity[[i, j]] - expected).abs() < 1e-12);
863            }
864        }
865    }
866
867    #[test]
868    fn cholesky_rejects_indefinite_matrices() {
869        let a = Array2::from_shape_vec((2, 2), vec![0.0, 1.0, 1.0, 0.0]).expect("shape");
870        assert!(cholesky(&a).is_err());
871    }
872}