Skip to main content

optirs_learned/
es_meta_training.rs

1//! Meta-training learned optimizers by evolution strategies.
2//!
3//! # What finding F75 was about
4//!
5//! [`GnnOptimizer`](crate::gnn_optimizer::GnnOptimizer) and
6//! [`NtmOptimizer`](crate::ntm_optimizer::NtmOptimizer) both have real forward
7//! maths — a genuine message-passing round with a GRU node update, and genuine
8//! NTM content/location addressing with erase-add memory writes. What was missing
9//! is that **nothing ever trained their weights**. Both drew their controller
10//! weights from the configured seed at construction and kept that draw forever, so
11//! "learned optimizer" meant "randomly initialised optimizer" in every use. The
12//! module docs disclosed it honestly, but a disclosed gap is still a gap.
13//!
14//! # Why evolution strategies and not backpropagation
15//!
16//! Meta-training a learned optimizer by gradients means differentiating through
17//! the unrolled inner optimization: `∂L_meta/∂w` has to flow back through
18//! `horizon` applications of
19//! [`AdvancedOptimizer::step`],
20//! each of which contains a recurrent cell, a nonlinear addressing or aggregation
21//! stage and a nonlinear readout. That is the truncated-BPTT machinery
22//! `lstm::bptt` (behind the `lstm` feature) implements for the LSTM controller,
23//! and doing it here
24//! would mean hand-deriving and maintaining a separate adjoint for each of two
25//! quite different architectures.
26//!
27//! Evolution strategies ([Salimans et al., 2017](https://arxiv.org/abs/1703.03864))
28//! estimate the same `∂L_meta/∂w` from forward rollouts alone:
29//!
30//! ```text
31//! ∇_w E[L(w + σ·ε)] = (1/σ) · E[ L(w + σ·ε) · ε ],   ε ~ N(0, I)
32//! ```
33//!
34//! evaluated here with **antithetic (mirrored) pairs**, which cancels the
35//! leading term of the estimator's variance:
36//!
37//! ```text
38//! ĝ = (1 / (pairs · σ)) · Σₖ ½·(L(w + σ·εₖ) − L(w − σ·εₖ)) · εₖ
39//! ```
40//!
41//! This is a real gradient estimator of a real objective, not a surrogate: every
42//! loss fed into it comes from actually running the optimizer on an actual task.
43//! It is far less sample-efficient than BPTT and is **not** presented as
44//! equivalent — [`MetaTrainingReport`] reports the measured meta-loss before and
45//! after, so a caller can see exactly what the training bought.
46//!
47//! # Meta-objective
48//!
49//! For each task the trainer rolls the optimizer out for `horizon` steps from the
50//! task's own start point and accumulates the loss curve, normalised by the
51//! initial loss:
52//!
53//! ```text
54//! L_task = (1 / horizon) · Σ_{t=1..horizon} loss_t / loss_0
55//! ```
56//!
57//! so `1.0` means "no progress", `0.0` means "solved immediately", and tasks of
58//! very different scale contribute comparably. `L_meta` is the mean over tasks. A
59//! non-finite rollout (a diverged optimizer) scores a large finite penalty rather
60//! than poisoning the average with `NaN`.
61
62use scirs2_core::ndarray::{Array1, Array2};
63use scirs2_core::numeric::Float;
64use scirs2_core::random::{Random, Rng};
65use std::fmt::Debug;
66
67use crate::domain_objectives::MetaObjective;
68use crate::domain_optimizers::AdvancedOptimizer;
69use crate::error::{OptimError, Result};
70
71/// Score assigned to a rollout that produced a non-finite loss.
72///
73/// Large enough to be strictly worse than any sane rollout (whose normalised
74/// score is bounded by ~1 unless the optimizer actively increases the loss), and
75/// finite so it cannot poison the population mean.
76const DIVERGENCE_PENALTY: f64 = 1.0e3;
77
78/// A learned optimizer whose weights an [`EsMetaTrainer`] can train.
79///
80/// The three requirements beyond [`AdvancedOptimizer`] are exactly what an ES
81/// loop needs: read the weights out as a flat vector, write a candidate vector
82/// back, and clear the persistent per-rollout state so tasks cannot contaminate
83/// each other.
84pub trait MetaTrainable<T>: AdvancedOptimizer<T> + Clone
85where
86    T: Float + Debug + Send + Sync + 'static,
87{
88    /// Every learned weight, flattened in an order that is stable across calls
89    /// for a given architecture, so that
90    /// `set_weight_vector(&weight_vector())` is the identity.
91    fn weight_vector(&self) -> Vec<f64>;
92
93    /// Overwrite every learned weight from a vector laid out like
94    /// [`Self::weight_vector`]'s output.
95    ///
96    /// # Errors
97    /// Must return an error — never partially apply — if `flat` is not exactly
98    /// the right length for this architecture.
99    fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()>;
100
101    /// Clear the persistent optimization state (momentum, hidden vectors,
102    /// memory, step count) while keeping the learned weights.
103    fn reset_state(&mut self);
104
105    /// Number of learned weights.
106    fn weight_count(&self) -> usize {
107        self.weight_vector().len()
108    }
109}
110
111/// Hyper-parameters for [`EsMetaTrainer`].
112#[derive(Debug, Clone)]
113pub struct MetaTrainingConfig {
114    /// Number of weight perturbations evaluated per iteration. Rounded **up** to
115    /// the next even number, because perturbations are drawn in mirrored pairs.
116    pub population: usize,
117    /// Number of ES iterations.
118    pub iterations: usize,
119    /// Perturbation standard deviation `σ`. Too small and the finite-difference
120    /// signal drowns in rollout noise; too large and the estimate stops being the
121    /// gradient of anything local.
122    pub sigma: f64,
123    /// Adam step size applied to the estimated meta-gradient.
124    pub meta_learning_rate: f64,
125    /// Inner-loop rollout length: how many optimizer steps each evaluation runs.
126    pub horizon: usize,
127    /// Seed for the perturbation draws, so a training run is reproducible.
128    pub seed: u64,
129}
130
131impl Default for MetaTrainingConfig {
132    fn default() -> Self {
133        Self {
134            population: 8,
135            iterations: 20,
136            sigma: 0.05,
137            meta_learning_rate: 0.05,
138            horizon: 20,
139            seed: 0x11E7_A5EE,
140        }
141    }
142}
143
144impl MetaTrainingConfig {
145    /// Validate the configuration.
146    fn validate(&self) -> Result<()> {
147        if self.population < 2 {
148            return Err(OptimError::InvalidConfig(
149                "population must be at least 2 (perturbations come in mirrored pairs)".to_string(),
150            ));
151        }
152        if self.iterations == 0 {
153            return Err(OptimError::InvalidConfig(
154                "iterations must be at least 1".to_string(),
155            ));
156        }
157        if !(self.sigma.is_finite() && self.sigma > 0.0) {
158            return Err(OptimError::InvalidConfig(
159                "sigma must be finite and positive".to_string(),
160            ));
161        }
162        if !(self.meta_learning_rate.is_finite() && self.meta_learning_rate > 0.0) {
163            return Err(OptimError::InvalidConfig(
164                "meta_learning_rate must be finite and positive".to_string(),
165            ));
166        }
167        if self.horizon == 0 {
168            return Err(OptimError::InvalidConfig(
169                "horizon must be at least 1".to_string(),
170            ));
171        }
172        Ok(())
173    }
174
175    /// Number of mirrored pairs actually evaluated.
176    fn pairs(&self) -> usize {
177        self.population.div_ceil(2)
178    }
179}
180
181/// Which meta-loss the trainer used to decide which weights to keep.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum SelectionMetric {
184    /// No validation tasks were supplied, so the training meta-loss chose the
185    /// weights. This measures fit, not generalisation: an ES run can drive the
186    /// training score down while making the optimizer *worse* on tasks outside
187    /// the training family, and selecting on this metric cannot detect that.
188    TrainingLoss,
189    /// A held-out validation set chose the weights, so the kept weights are the
190    /// ones that generalised best among those visited.
191    ValidationLoss,
192}
193
194/// What a meta-training run measured.
195///
196/// Every field is an observation, not a target: `final_meta_loss` is the value the
197/// kept weights actually achieve on the training tasks, and [`Self::improved`] is
198/// the honest answer to "did this help", judged by whichever criterion
199/// [`Self::selection_metric`] names.
200#[derive(Debug, Clone)]
201pub struct MetaTrainingReport {
202    /// ES iterations completed.
203    pub iterations: usize,
204    /// Training meta-loss of the weights the trainer started from.
205    pub initial_meta_loss: f64,
206    /// Training meta-loss of the weights the trainer kept.
207    pub final_meta_loss: f64,
208    /// Training meta-loss after each iteration, in order.
209    pub loss_history: Vec<f64>,
210    /// Validation meta-loss of the starting weights, if validation tasks were
211    /// supplied.
212    pub initial_validation_loss: Option<f64>,
213    /// Validation meta-loss of the weights the trainer kept.
214    pub final_validation_loss: Option<f64>,
215    /// Validation meta-loss after each iteration, if validation tasks were
216    /// supplied.
217    pub validation_history: Vec<f64>,
218    /// Which loss chose the kept weights.
219    pub selection_metric: SelectionMetric,
220    /// Euclidean norm of the estimated meta-gradient at each iteration.
221    pub gradient_norms: Vec<f64>,
222    /// Number of learned weights being optimized.
223    pub weight_count: usize,
224}
225
226impl MetaTrainingReport {
227    /// The initial and final values of whichever loss chose the weights.
228    fn selection_pair(&self) -> (f64, f64) {
229        match self.selection_metric {
230            SelectionMetric::TrainingLoss => (self.initial_meta_loss, self.final_meta_loss),
231            SelectionMetric::ValidationLoss => (
232                self.initial_validation_loss
233                    .unwrap_or(self.initial_meta_loss),
234                self.final_validation_loss.unwrap_or(self.final_meta_loss),
235            ),
236        }
237    }
238
239    /// Whether the run left the optimizer better than it found it, judged by the
240    /// selection metric.
241    ///
242    /// # This is weaker than it sounds under [`SelectionMetric::TrainingLoss`]
243    ///
244    /// With no validation tasks there is nothing to compare the training loss
245    /// against, so `improved() == true` means only "it fits the training tasks
246    /// better" — which is exactly what an *overfitting* run also reports. The NTM
247    /// controller was measured driving its training meta-loss down ~30% while
248    /// making a held-out task 40x worse; that run returns `true` here, and under
249    /// `TrainingLoss` [`Self::relative_improvement`] and
250    /// [`Self::relative_training_improvement`] are the same number, so the report
251    /// carries no signal that separates the two cases.
252    ///
253    /// To get a claim about generalisation, train through
254    /// [`EsMetaTrainer::train_with_validation`]: the metric becomes
255    /// [`SelectionMetric::ValidationLoss`], and the gap between
256    /// `relative_improvement()` and `relative_training_improvement()` becomes the
257    /// overfitting measurement. Check [`Self::selection_metric`] before trusting
258    /// this method as a generalisation result.
259    pub fn improved(&self) -> bool {
260        let (initial, final_) = self.selection_pair();
261        final_ < initial
262    }
263
264    /// Fractional reduction in the selection metric (negative if training made it
265    /// worse).
266    pub fn relative_improvement(&self) -> f64 {
267        let (initial, final_) = self.selection_pair();
268        if initial.abs() <= f64::EPSILON {
269            return 0.0;
270        }
271        (initial - final_) / initial.abs()
272    }
273
274    /// Fractional reduction in the *training* meta-loss specifically.
275    ///
276    /// Comparing this against [`Self::relative_improvement`] under
277    /// [`SelectionMetric::ValidationLoss`] is how a caller sees overfitting: a
278    /// large training improvement next to a small validation one means the run
279    /// specialised to the training family.
280    pub fn relative_training_improvement(&self) -> f64 {
281        if self.initial_meta_loss.abs() <= f64::EPSILON {
282            return 0.0;
283        }
284        (self.initial_meta_loss - self.final_meta_loss) / self.initial_meta_loss.abs()
285    }
286}
287
288/// Evolution-strategies meta-trainer for any [`MetaTrainable`] optimizer.
289#[derive(Debug, Clone)]
290pub struct EsMetaTrainer {
291    config: MetaTrainingConfig,
292}
293
294impl EsMetaTrainer {
295    /// Build a trainer.
296    ///
297    /// # Errors
298    /// Returns [`OptimError::InvalidConfig`] for an out-of-range hyper-parameter.
299    pub fn new(config: MetaTrainingConfig) -> Result<Self> {
300        config.validate()?;
301        Ok(Self { config })
302    }
303
304    /// The configuration in use.
305    pub fn config(&self) -> &MetaTrainingConfig {
306        &self.config
307    }
308
309    /// Roll `optimizer` out on every task and return the mean normalised loss
310    /// curve — the meta-objective, measured, with no training involved.
311    ///
312    /// The optimizer's persistent state is reset before each task, so tasks do not
313    /// contaminate each other and the score depends only on the weights. The
314    /// optimizer passed in is never mutated.
315    ///
316    /// # Errors
317    /// Returns an error if `tasks` is empty, if a task is internally inconsistent,
318    /// or if the objective cannot be evaluated.
319    pub fn meta_loss<T, O>(&self, optimizer: &O, tasks: &[&dyn MetaObjective<T>]) -> Result<f64>
320    where
321        T: Float + Debug + Send + Sync + 'static,
322        O: MetaTrainable<T>,
323    {
324        if tasks.is_empty() {
325            return Err(OptimError::InsufficientData(
326                "meta_loss needs at least one task".to_string(),
327            ));
328        }
329        let mut total = 0.0;
330        for task in tasks {
331            total += self.rollout_score(optimizer, *task)?;
332        }
333        Ok(total / tasks.len() as f64)
334    }
335
336    /// One task's normalised loss-curve score.
337    fn rollout_score<T, O>(&self, optimizer: &O, task: &dyn MetaObjective<T>) -> Result<f64>
338    where
339        T: Float + Debug + Send + Sync + 'static,
340        O: MetaTrainable<T>,
341    {
342        let mut runner = optimizer.clone();
343        runner.reset_state();
344
345        let mut params = task.initial_parameters();
346        if params.len() != task.dimension() {
347            return Err(OptimError::InvalidConfig(format!(
348                "task '{}' reported dimension {} but produced {} initial parameters",
349                task.name(),
350                task.dimension(),
351                params.len()
352            )));
353        }
354
355        let (initial_loss, _) = task.loss_and_gradient(&params)?;
356        let initial = initial_loss.to_f64().unwrap_or(f64::NAN);
357        if !initial.is_finite() || initial.abs() <= f64::EPSILON {
358            // Nothing to improve on (or an unusable task): score it neutrally
359            // rather than dividing by ~0 and manufacturing an enormous gradient.
360            return Ok(1.0);
361        }
362
363        let mut accumulated = 0.0;
364        for _ in 0..self.config.horizon {
365            let (_, gradient) = task.loss_and_gradient(&params)?;
366            params = runner.step(&params, &gradient)?;
367            let (loss, _) = task.loss_and_gradient(&params)?;
368            let value = loss.to_f64().unwrap_or(f64::NAN);
369            if !value.is_finite() {
370                return Ok(DIVERGENCE_PENALTY);
371            }
372            accumulated += value / initial;
373        }
374        let score = accumulated / self.config.horizon as f64;
375        if score.is_finite() {
376            Ok(score)
377        } else {
378            Ok(DIVERGENCE_PENALTY)
379        }
380    }
381
382    /// Meta-train `optimizer`'s weights in place, selecting on the training loss.
383    ///
384    /// Equivalent to [`Self::train_with_validation`] with no validation tasks.
385    /// **Prefer the validation form**: selecting on the training loss measures fit,
386    /// not generalisation, and an ES run can drive the training score down while
387    /// making the optimizer strictly worse outside the training family. The NTM
388    /// controller does exactly that on a family of quadratics — training loss down
389    /// ~30%, held-out loss up by a factor of 40 — which is why the validation
390    /// entry point exists.
391    ///
392    /// # Errors
393    /// Returns an error if `tasks` is empty, if the optimizer reports no learned
394    /// weights, if a task is inconsistent, or if the objective cannot be
395    /// evaluated.
396    pub fn train<T, O>(
397        &self,
398        optimizer: &mut O,
399        tasks: &[&dyn MetaObjective<T>],
400    ) -> Result<MetaTrainingReport>
401    where
402        T: Float + Debug + Send + Sync + 'static,
403        O: MetaTrainable<T>,
404    {
405        self.train_with_validation(optimizer, tasks, &[])
406    }
407
408    /// Meta-train `optimizer`'s weights in place, selecting the kept weights by
409    /// their score on `validation`.
410    ///
411    /// The ES gradient is always estimated from `tasks`; `validation` only decides
412    /// *which* of the visited weight vectors is kept. That separation is what makes
413    /// the result a generalisation claim: the trainer never optimizes against the
414    /// validation tasks, it only measures against them.
415    ///
416    /// The best weights *seen* are kept, never simply the last ones visited — an ES
417    /// run on a noisy objective can wander uphill, and silently returning weights
418    /// worse than the caller handed in would be the same class of dishonesty this
419    /// module exists to remove. [`MetaTrainingReport::improved`] reports which
420    /// happened, and comparing it with
421    /// [`MetaTrainingReport::relative_training_improvement`] exposes overfitting.
422    ///
423    /// Pass an empty `validation` slice to select on the training loss instead.
424    ///
425    /// # Errors
426    /// Returns an error if `tasks` is empty, if the optimizer reports no learned
427    /// weights, if a task is inconsistent, or if an objective cannot be evaluated.
428    pub fn train_with_validation<T, O>(
429        &self,
430        optimizer: &mut O,
431        tasks: &[&dyn MetaObjective<T>],
432        validation: &[&dyn MetaObjective<T>],
433    ) -> Result<MetaTrainingReport>
434    where
435        T: Float + Debug + Send + Sync + 'static,
436        O: MetaTrainable<T>,
437    {
438        if tasks.is_empty() {
439            return Err(OptimError::InsufficientData(
440                "meta-training needs at least one task".to_string(),
441            ));
442        }
443
444        let baseline_weights = optimizer.weight_vector();
445        let dimension = baseline_weights.len();
446        if dimension == 0 {
447            return Err(OptimError::InvalidState(
448                "the optimizer reported zero learned weights".to_string(),
449            ));
450        }
451
452        let selection_metric = if validation.is_empty() {
453            SelectionMetric::TrainingLoss
454        } else {
455            SelectionMetric::ValidationLoss
456        };
457
458        let initial_meta_loss = self.meta_loss(optimizer, tasks)?;
459        let initial_validation_loss = if validation.is_empty() {
460            None
461        } else {
462            Some(self.meta_loss(optimizer, validation)?)
463        };
464
465        let mut weights = baseline_weights.clone();
466        let mut best_weights = baseline_weights;
467        let mut best_training = initial_meta_loss;
468        let mut best_validation = initial_validation_loss;
469        // The value being minimised for selection purposes.
470        let mut best_selection = match selection_metric {
471            SelectionMetric::TrainingLoss => initial_meta_loss,
472            SelectionMetric::ValidationLoss => initial_validation_loss.unwrap_or(initial_meta_loss),
473        };
474        let mut validation_history = Vec::with_capacity(self.config.iterations);
475
476        // Adam moments over the ES gradient estimate.
477        let mut moment1 = vec![0.0; dimension];
478        let mut moment2 = vec![0.0; dimension];
479        const BETA1: f64 = 0.9;
480        const BETA2: f64 = 0.999;
481        const EPSILON: f64 = 1e-8;
482
483        let mut rng = Random::seed(self.config.seed);
484        let pairs = self.config.pairs();
485        let mut loss_history = Vec::with_capacity(self.config.iterations);
486        let mut gradient_norms = Vec::with_capacity(self.config.iterations);
487        let mut probe = optimizer.clone();
488
489        for iteration in 0..self.config.iterations {
490            let mut perturbations: Vec<Vec<f64>> = Vec::with_capacity(pairs);
491            let mut differences: Vec<f64> = Vec::with_capacity(pairs);
492            let mut evaluations: Vec<f64> = Vec::with_capacity(2 * pairs);
493
494            for _ in 0..pairs {
495                let epsilon = standard_normal_vector(&mut rng, dimension);
496
497                let mut plus = weights.clone();
498                let mut minus = weights.clone();
499                for i in 0..dimension {
500                    plus[i] += self.config.sigma * epsilon[i];
501                    minus[i] -= self.config.sigma * epsilon[i];
502                }
503
504                probe.set_weight_vector(&plus)?;
505                let loss_plus = self.meta_loss(&probe, tasks)?;
506                probe.set_weight_vector(&minus)?;
507                let loss_minus = self.meta_loss(&probe, tasks)?;
508
509                evaluations.push(loss_plus);
510                evaluations.push(loss_minus);
511                differences.push(0.5 * (loss_plus - loss_minus));
512                perturbations.push(epsilon);
513            }
514
515            // Fitness normalisation (Salimans et al. §2): dividing the centred
516            // differences by their spread makes the step size independent of the
517            // objective's scale, which matters because the normalised loss curve
518            // shrinks as training succeeds.
519            let spread = population_std(&evaluations);
520            let scale = if spread > 1e-12 { 1.0 / spread } else { 1.0 };
521
522            let mut gradient = vec![0.0; dimension];
523            for (epsilon, difference) in perturbations.iter().zip(differences.iter()) {
524                let weight = difference * scale;
525                for i in 0..dimension {
526                    gradient[i] += weight * epsilon[i];
527                }
528            }
529            let normaliser = 1.0 / (pairs as f64 * self.config.sigma);
530            for value in gradient.iter_mut() {
531                *value *= normaliser;
532            }
533            gradient_norms.push(gradient.iter().map(|g| g * g).sum::<f64>().sqrt());
534
535            // Adam step. ES estimates ∇L and we are minimising, so we descend.
536            let step = iteration as i32 + 1;
537            let bias1 = 1.0 - BETA1.powi(step);
538            let bias2 = 1.0 - BETA2.powi(step);
539            for i in 0..dimension {
540                moment1[i] = BETA1 * moment1[i] + (1.0 - BETA1) * gradient[i];
541                moment2[i] = BETA2 * moment2[i] + (1.0 - BETA2) * gradient[i] * gradient[i];
542                let m_hat = moment1[i] / bias1;
543                let v_hat = moment2[i] / bias2;
544                weights[i] -= self.config.meta_learning_rate * m_hat / (v_hat.sqrt() + EPSILON);
545            }
546
547            probe.set_weight_vector(&weights)?;
548            let training_loss = self.meta_loss(&probe, tasks)?;
549            loss_history.push(training_loss);
550
551            let validation_loss = if validation.is_empty() {
552                None
553            } else {
554                let value = self.meta_loss(&probe, validation)?;
555                validation_history.push(value);
556                Some(value)
557            };
558
559            let selection = match selection_metric {
560                SelectionMetric::TrainingLoss => training_loss,
561                SelectionMetric::ValidationLoss => validation_loss.unwrap_or(training_loss),
562            };
563            if selection < best_selection {
564                best_selection = selection;
565                best_training = training_loss;
566                best_validation = validation_loss;
567                best_weights = weights.clone();
568            }
569        }
570
571        optimizer.set_weight_vector(&best_weights)?;
572        optimizer.reset_state();
573
574        Ok(MetaTrainingReport {
575            iterations: self.config.iterations,
576            initial_meta_loss,
577            final_meta_loss: best_training,
578            loss_history,
579            initial_validation_loss,
580            final_validation_loss: best_validation,
581            validation_history,
582            selection_metric,
583            gradient_norms,
584            weight_count: dimension,
585        })
586    }
587}
588
589/// Population standard deviation of `values` (`0` for fewer than two samples).
590fn population_std(values: &[f64]) -> f64 {
591    if values.len() < 2 {
592        return 0.0;
593    }
594    let n = values.len() as f64;
595    let mean = values.iter().sum::<f64>() / n;
596    let variance = values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
597    variance.sqrt()
598}
599
600/// Draw `len` independent `N(0, 1)` samples by the Box–Muller transform.
601///
602/// `scirs2_core::random` gives uniforms, and Box–Muller turns a pair of them into
603/// an exact pair of standard normals — no approximation, no extra dependency. The
604/// lower bound is nudged off zero so `ln(u)` is always finite.
605pub(crate) fn standard_normal_vector<R: Rng>(rng: &mut Random<R>, len: usize) -> Vec<f64> {
606    let mut out = Vec::with_capacity(len);
607    while out.len() < len {
608        let u1: f64 = rng.random_range(f64::MIN_POSITIVE..1.0);
609        let u2: f64 = rng.random_range(0.0..1.0);
610        let radius = (-2.0 * u1.ln()).sqrt();
611        let angle = std::f64::consts::TAU * u2;
612        out.push(radius * angle.cos());
613        if out.len() < len {
614            out.push(radius * angle.sin());
615        }
616    }
617    out
618}
619
620// ---------------------------------------------------------------------------
621// Flat weight-vector plumbing, shared by every `MetaTrainable` implementation.
622// ---------------------------------------------------------------------------
623
624/// Append every element of `matrix` to `out`, row-major.
625pub(crate) fn push_matrix<T: Float>(matrix: &Array2<T>, out: &mut Vec<f64>) {
626    out.extend(matrix.iter().map(|v| v.to_f64().unwrap_or(0.0)));
627}
628
629/// Append every element of `vector` to `out`.
630pub(crate) fn push_vector<T: Float>(vector: &Array1<T>, out: &mut Vec<f64>) {
631    out.extend(vector.iter().map(|v| v.to_f64().unwrap_or(0.0)));
632}
633
634/// Append a single scalar to `out`.
635pub(crate) fn push_scalar<T: Float>(scalar: T, out: &mut Vec<f64>) {
636    out.push(scalar.to_f64().unwrap_or(0.0));
637}
638
639/// Read `matrix.len()` values out of `flat` at `*cursor` into `matrix`, row-major.
640pub(crate) fn pull_matrix<T: Float>(
641    matrix: &mut Array2<T>,
642    flat: &[f64],
643    cursor: &mut usize,
644) -> Result<()> {
645    let end = *cursor + matrix.len();
646    let slice = take(flat, *cursor, end)?;
647    for (target, value) in matrix.iter_mut().zip(slice.iter()) {
648        *target = scirs2_core::numeric::NumCast::from(*value).unwrap_or_else(|| T::zero());
649    }
650    *cursor = end;
651    Ok(())
652}
653
654/// Read `vector.len()` values out of `flat` at `*cursor` into `vector`.
655pub(crate) fn pull_vector<T: Float>(
656    vector: &mut Array1<T>,
657    flat: &[f64],
658    cursor: &mut usize,
659) -> Result<()> {
660    let end = *cursor + vector.len();
661    let slice = take(flat, *cursor, end)?;
662    for (target, value) in vector.iter_mut().zip(slice.iter()) {
663        *target = scirs2_core::numeric::NumCast::from(*value).unwrap_or_else(|| T::zero());
664    }
665    *cursor = end;
666    Ok(())
667}
668
669/// Read one value out of `flat` at `*cursor` into `scalar`.
670pub(crate) fn pull_scalar<T: Float>(
671    scalar: &mut T,
672    flat: &[f64],
673    cursor: &mut usize,
674) -> Result<()> {
675    let slice = take(flat, *cursor, *cursor + 1)?;
676    *scalar = scirs2_core::numeric::NumCast::from(slice[0]).unwrap_or_else(|| T::zero());
677    *cursor += 1;
678    Ok(())
679}
680
681/// Borrow `flat[start..end]`, or report exactly how short the vector is.
682fn take(flat: &[f64], start: usize, end: usize) -> Result<&[f64]> {
683    flat.get(start..end).ok_or_else(|| {
684        OptimError::InvalidConfig(format!(
685            "weight vector is too short: needed {end} values, got {}",
686            flat.len()
687        ))
688    })
689}
690
691/// Fail unless the cursor consumed the whole vector.
692///
693/// A partial load would leave the network in a state no caller could reason
694/// about, so an over-long vector is an error just like a short one.
695pub(crate) fn expect_fully_consumed(flat: &[f64], cursor: usize) -> Result<()> {
696    if cursor != flat.len() {
697        return Err(OptimError::InvalidConfig(format!(
698            "weight vector has {} values but this architecture needs exactly {cursor}",
699            flat.len()
700        )));
701    }
702    Ok(())
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn box_muller_produces_standard_normals() {
711        let mut rng = Random::seed(7);
712        let samples = standard_normal_vector(&mut rng, 4000);
713        assert_eq!(samples.len(), 4000);
714        assert!(samples.iter().all(|v| v.is_finite()));
715        let mean = samples.iter().sum::<f64>() / samples.len() as f64;
716        let variance =
717            samples.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / samples.len() as f64;
718        assert!(mean.abs() < 0.08, "mean {mean} is not ~0");
719        assert!(
720            (variance - 1.0).abs() < 0.12,
721            "variance {variance} is not ~1"
722        );
723        // An odd length must still be exact.
724        assert_eq!(standard_normal_vector(&mut rng, 7).len(), 7);
725    }
726
727    #[test]
728    fn population_std_matches_the_definition() {
729        assert_eq!(population_std(&[]), 0.0);
730        assert_eq!(population_std(&[3.0]), 0.0);
731        // Values 1,2,3,4: mean 2.5, variance 1.25, std sqrt(1.25).
732        let observed = population_std(&[1.0, 2.0, 3.0, 4.0]);
733        assert!((observed - 1.25_f64.sqrt()).abs() < 1e-12, "{observed}");
734    }
735
736    #[test]
737    fn bad_configurations_are_rejected() {
738        for config in [
739            MetaTrainingConfig {
740                population: 1,
741                ..Default::default()
742            },
743            MetaTrainingConfig {
744                iterations: 0,
745                ..Default::default()
746            },
747            MetaTrainingConfig {
748                sigma: 0.0,
749                ..Default::default()
750            },
751            MetaTrainingConfig {
752                sigma: f64::NAN,
753                ..Default::default()
754            },
755            MetaTrainingConfig {
756                meta_learning_rate: -1.0,
757                ..Default::default()
758            },
759            MetaTrainingConfig {
760                horizon: 0,
761                ..Default::default()
762            },
763        ] {
764            assert!(EsMetaTrainer::new(config).is_err());
765        }
766        let trainer = EsMetaTrainer::new(MetaTrainingConfig::default()).expect("default is valid");
767        assert_eq!(trainer.config().population, 8);
768    }
769
770    #[test]
771    fn odd_populations_round_up_to_whole_mirrored_pairs() {
772        let config = MetaTrainingConfig {
773            population: 7,
774            ..Default::default()
775        };
776        assert_eq!(config.pairs(), 4, "7 perturbations means 4 mirrored pairs");
777    }
778
779    #[test]
780    fn flat_plumbing_round_trips_and_rejects_bad_lengths() {
781        use scirs2_core::ndarray::{Array1, Array2};
782
783        let matrix = Array2::from_shape_fn((2, 3), |(i, j)| (i * 3 + j) as f64);
784        let vector = Array1::from_vec(vec![10.0, 11.0]);
785        let scalar = 12.5_f64;
786
787        let mut flat = Vec::new();
788        push_matrix(&matrix, &mut flat);
789        push_vector(&vector, &mut flat);
790        push_scalar(scalar, &mut flat);
791        assert_eq!(flat.len(), 6 + 2 + 1);
792
793        let mut out_matrix = Array2::<f64>::zeros((2, 3));
794        let mut out_vector = Array1::<f64>::zeros(2);
795        let mut out_scalar = 0.0_f64;
796        let mut cursor = 0usize;
797        pull_matrix(&mut out_matrix, &flat, &mut cursor).expect("matrix");
798        pull_vector(&mut out_vector, &flat, &mut cursor).expect("vector");
799        pull_scalar(&mut out_scalar, &flat, &mut cursor).expect("scalar");
800        expect_fully_consumed(&flat, cursor).expect("consumed");
801
802        assert_eq!(out_matrix, matrix);
803        assert_eq!(out_vector, vector);
804        assert_eq!(out_scalar, scalar);
805
806        // Short vector -> error, not a partial load.
807        let mut cursor = 0usize;
808        let mut target = Array2::<f64>::zeros((2, 3));
809        assert!(pull_matrix(&mut target, &flat[..4], &mut cursor).is_err());
810        // Over-long vector -> error too.
811        assert!(expect_fully_consumed(&flat, 3).is_err());
812    }
813}