Skip to main content

rlevo_evolution/algorithms/
cma_es.rs

1//! Covariance Matrix Adaptation Evolution Strategy (CMA-ES).
2//!
3//! CMA-ES (Hansen & Ostermeier, 2001; Hansen, 2016) samples each generation
4//! from a multivariate normal `N(m, σ²C)` and adapts the mean `m`, the global
5//! step size `σ`, and the covariance matrix `C` from the ranked offspring. Two
6//! evolution paths drive the adaptation:
7//!
8//! - the **conjugate path** `p_σ` feeds Cumulative Step-size Adaptation (CSA),
9//!   which lengthens or shrinks `σ` depending on whether consecutive steps are
10//!   correlated or anti-correlated;
11//! - the **anisotropic path** `p_c` feeds the rank-1 update of `C`.
12//!
13//! A rank-μ update mixes in the empirical covariance of the selected steps. The
14//! conjugate path requires `C^{-1/2}`, obtained from a symmetric
15//! eigendecomposition of `C` (see [`crate::ops::linalg::jacobi_eigen`]).
16//!
17//! # Relationship to the EDA / `ProbabilityModel` family
18//!
19//! A full-covariance multivariate-Gaussian EDA (EMNA) is CMA-ES *minus* the
20//! evolution paths and step-size decoupling: it re-estimates `m`/`C` by maximum
21//! likelihood each generation. CMA-ES keeps the path-based momentum and CSA, so
22//! it does **not** fit the [`ProbabilityModel`](crate::ProbabilityModel)
23//! `fit → sample` seam — the CSA and path updates live in
24//! [`Strategy::tell`], not in a model fit. Per ADR 0021 this strategy is a
25//! self-contained [`Strategy`]; `ProbabilityModel<B>` is available but
26//! deliberately unused (research note `eda-vs-cma-es-boundary`). For the
27//! path-free sibling that self-adapts σ per individual, see
28//! [`crate::algorithms::cmsa_es`].
29//!
30//! # References
31//!
32//! - Hansen, N. (2016), *The CMA Evolution Strategy: A Tutorial*,
33//!   arXiv:1604.00772 (default parameters: Table 1).
34//! - Hansen, N. & Ostermeier, A. (2001), *Completely Derandomized
35//!   Self-Adaptation in Evolution Strategies*, Evolutionary Computation 9(2).
36
37use std::marker::PhantomData;
38
39use burn::tensor::{Tensor, TensorData, backend::Backend};
40use rand::Rng;
41use rand::RngExt;
42
43use rlevo_core::bounds::Bounds;
44use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate, Violations};
45
46use crate::ops::linalg::{SymEigen, jacobi_eigen, matvec, symmetrize};
47use crate::rng::{SeedPurpose, seed_stream};
48use crate::strategy::{Strategy, StrategyMetrics};
49
50/// Absolute backstop floor for eigenvalues (guards against an all-zero `C`).
51const EIGENVALUE_FLOOR: f32 = 1e-20;
52
53/// Relative eigenvalue floor: eigenvalues below `λ_max · CONDITION_FLOOR` are
54/// clamped before taking `√Λ` / `1/√Λ`, capping the covariance condition number
55/// near `1e14` (pycma's condition-number treatment). Without this, a single
56/// eigenvalue drifting toward zero would make a `C^{-1/2}` column explode and
57/// drive `σ` to `+∞` through the CSA update.
58const CONDITION_FLOOR: f32 = 1e-14;
59
60/// Per-eigenvalue floor for the current covariance: the larger of the absolute
61/// backstop and `λ_max · CONDITION_FLOOR`.
62fn eigenvalue_floor(eigvals: &[f32]) -> f32 {
63    let lmax: f32 = eigvals.iter().copied().fold(0.0_f32, f32::max);
64    (lmax * CONDITION_FLOOR).max(EIGENVALUE_FLOOR)
65}
66
67/// Static configuration for a CMA-ES run.
68///
69/// Construct with [`CmaEsConfig::default_for`] (derives `λ` from the dimension
70/// per Hansen 2016) or [`CmaEsConfig::with_pop_size`] (explicit `λ`, e.g. a
71/// larger population for multimodal landscapes). The recombination weights and
72/// learning rates are all derived from `(λ, D)` and cached as fields so
73/// [`Strategy::tell`] reads them without recomputing.
74#[derive(Debug, Clone)]
75pub struct CmaEsConfig {
76    /// Offspring population size `λ`.
77    pub pop_size: usize,
78    /// Genome dimensionality `D`.
79    pub genome_dim: usize,
80    /// Search-space bounds; used only to sample the initial mean `m⁰`.
81    /// Offspring are **not** clamped (CMA-ES samples in unbounded ℝᴰ).
82    pub bounds: Bounds,
83    /// Initial global step size `σ`.
84    pub initial_sigma: f32,
85    /// Number of selected parents `μ = ⌊λ/2⌋`.
86    pub mu: usize,
87    /// Recombination weights `wᵢ` (length `μ`, positive, summing to 1).
88    pub weights: Vec<f32>,
89    /// Variance-effective selection mass `μ_eff = 1 / Σ wᵢ²`.
90    pub mu_eff: f32,
91    /// CSA learning rate `c_σ`.
92    pub c_sigma: f32,
93    /// CSA damping `d_σ`.
94    pub d_sigma: f32,
95    /// Anisotropic-path learning rate `c_c`.
96    pub c_c: f32,
97    /// Rank-1 covariance learning rate `c_1`.
98    pub c_1: f32,
99    /// Rank-μ covariance learning rate `c_μ`.
100    pub c_mu: f32,
101    /// Expected length of `N(0, I)`, `χ_n ≈ √D (1 − 1/4D + 1/21D²)`.
102    pub chi_n: f32,
103}
104
105impl CmaEsConfig {
106    /// Default configuration for dimensionality `D`, with the Hansen-2016
107    /// population `λ = 4 + ⌊3 ln D⌋`.
108    ///
109    /// Sets `bounds = (-5.12, 5.12)` (the standard Sphere/Rastrigin domain) and
110    /// `initial_sigma = 1.0`.
111    #[must_use]
112    pub fn default_for(genome_dim: usize) -> Self {
113        #[allow(clippy::cast_precision_loss)]
114        let d = genome_dim as f32;
115        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
116        let lambda = 4 + (3.0 * d.ln()).floor() as usize;
117        Self::with_pop_size(lambda, genome_dim)
118    }
119
120    /// Configuration with an explicit population size `λ`.
121    ///
122    /// Larger `λ` improves basin-finding on multimodal landscapes (Hansen 2016,
123    /// §A); all derived weights and learning rates follow from `(λ, D)`.
124    ///
125    /// The `pop_size ≥ 2` invariant is enforced by [`Validate::validate`] at the
126    /// harness chokepoint, not by this infallible producer.
127    #[must_use]
128    pub fn with_pop_size(pop_size: usize, genome_dim: usize) -> Self {
129        #[allow(clippy::cast_precision_loss)]
130        let d = genome_dim as f32;
131        let mu: usize = pop_size / 2;
132
133        // Positive recombination weights w'ᵢ = ln(μ + ½) − ln(i), normalized.
134        let raw: Vec<f32> = (1..=mu)
135            .map(|i| {
136                #[allow(clippy::cast_precision_loss)]
137                let fi = i as f32;
138                #[allow(clippy::cast_precision_loss)]
139                let mu_f = mu as f32;
140                (mu_f + 0.5).ln() - fi.ln()
141            })
142            .collect();
143        let sum: f32 = raw.iter().sum();
144        let weights: Vec<f32> = raw.iter().map(|w| w / sum).collect();
145        let sum_sq: f32 = weights.iter().map(|w| w * w).sum();
146        let mu_eff: f32 = 1.0 / sum_sq;
147
148        let c_sigma: f32 = (mu_eff + 2.0) / (d + mu_eff + 5.0);
149        let d_sigma: f32 =
150            1.0 + 2.0 * (((mu_eff - 1.0) / (d + 1.0)).sqrt() - 1.0).max(0.0) + c_sigma;
151        let c_c: f32 = (4.0 + mu_eff / d) / (d + 4.0 + 2.0 * mu_eff / d);
152        let c_1: f32 = 2.0 / ((d + 1.3) * (d + 1.3) + mu_eff);
153        let c_mu: f32 =
154            (1.0 - c_1).min(2.0 * (mu_eff - 2.0 + 1.0 / mu_eff) / ((d + 2.0) * (d + 2.0) + mu_eff));
155        let chi_n: f32 = d.sqrt() * (1.0 - 1.0 / (4.0 * d) + 1.0 / (21.0 * d * d));
156
157        Self {
158            pop_size,
159            genome_dim,
160            bounds: Bounds::new(-5.12, 5.12),
161            initial_sigma: 1.0,
162            mu,
163            weights,
164            mu_eff,
165            c_sigma,
166            d_sigma,
167            c_c,
168            c_1,
169            c_mu,
170            chi_n,
171        }
172    }
173}
174
175impl Validate for CmaEsConfig {
176    /// Fail-fast: reports the first violation, derived from [`validate_all`] so
177    /// the two never disagree.
178    ///
179    /// [`validate_all`]: CmaEsConfig::validate_all
180    fn validate(&self) -> Result<(), ConfigError> {
181        self.validate_all().map_err(|mut errs| errs.remove(0))
182    }
183
184    /// Accumulate-all: reports every violated invariant in one pass.
185    ///
186    /// Unlike most configs, `CmaEsConfig` exposes its **derived** fields —
187    /// recombination `weights`, `mu_eff`, and the five learning rates — as
188    /// public struct fields (so callers can construct one by hand). The
189    /// [`default_for`] / [`with_pop_size`] producers keep them mutually
190    /// consistent, but a hand-built literal can desync several at once; listing
191    /// all violations then beats fixing them one recompile at a time.
192    ///
193    /// [`default_for`]: CmaEsConfig::default_for
194    /// [`with_pop_size`]: CmaEsConfig::with_pop_size
195    fn validate_all(&self) -> Result<(), Vec<ConfigError>> {
196        const C: &str = "CmaEsConfig";
197        let mut v = Violations::new();
198
199        // Primary inputs.
200        v.check(config::at_least(C, "pop_size", self.pop_size, 2));
201        v.check(config::nonzero(C, "genome_dim", self.genome_dim));
202        v.check(config::positive(
203            C,
204            "initial_sigma",
205            f64::from(self.initial_sigma),
206        ));
207        v.check(config::at_least(C, "mu", self.mu, 1));
208        if self.mu > self.pop_size {
209            v.check(Err(ConfigError {
210                config: C,
211                field: "mu",
212                kind: ConstraintKind::Custom("mu must not exceed pop_size"),
213            }));
214        }
215
216        // Derived recombination weights: length μ, strictly positive, sum ≈ 1.
217        if self.weights.len() != self.mu {
218            v.check(Err(ConfigError {
219                config: C,
220                field: "weights",
221                kind: ConstraintKind::Custom("weights length must equal mu"),
222            }));
223        }
224        if !self.weights.iter().all(|w| *w > 0.0) {
225            v.check(Err(ConfigError {
226                config: C,
227                field: "weights",
228                kind: ConstraintKind::Custom("recombination weights must all be positive"),
229            }));
230        }
231        let weight_sum = f64::from(self.weights.iter().sum::<f32>());
232        v.check(config::in_range(
233            C,
234            "weights",
235            1.0 - 1e-3,
236            1.0 + 1e-3,
237            weight_sum,
238        ));
239
240        // Derived scalars. mu_eff = 1/Σwᵢ² ≥ 1; d_sigma and chi_n are positive
241        // denominators/scales — a non-positive value diverges the step-size
242        // control or the covariance update.
243        v.check(config::in_range(
244            C,
245            "mu_eff",
246            1.0,
247            f64::INFINITY,
248            f64::from(self.mu_eff),
249        ));
250        v.check(config::positive(C, "d_sigma", f64::from(self.d_sigma)));
251        v.check(config::positive(C, "chi_n", f64::from(self.chi_n)));
252
253        // Covariance/step-size learning rates each live in [0, 1], and the pair
254        // (c_1, c_mu) must not sum past 1: the rank-update retention factor is
255        // `1 − c_1 − c_mu`, so c_1 + c_mu > 1 turns it negative and the
256        // covariance matrix loses positive-definiteness.
257        v.check(config::in_range(
258            C,
259            "c_sigma",
260            0.0,
261            1.0,
262            f64::from(self.c_sigma),
263        ));
264        v.check(config::in_range(C, "c_c", 0.0, 1.0, f64::from(self.c_c)));
265        v.check(config::in_range(C, "c_1", 0.0, 1.0, f64::from(self.c_1)));
266        v.check(config::in_range(C, "c_mu", 0.0, 1.0, f64::from(self.c_mu)));
267        v.check(config::in_range(
268            C,
269            "c_1_plus_c_mu",
270            0.0,
271            1.0,
272            f64::from(self.c_1) + f64::from(self.c_mu),
273        ));
274
275        v.into_result()
276    }
277}
278
279/// Generation state for [`CmaEs`].
280///
281/// All adaptive quantities live here (not in [`CmaEsConfig`]) so instances stay
282/// lock-free across parallel runs. Linear-algebra state — the mean, covariance,
283/// and evolution paths — is held host-side as `Vec<f32>`; only the offspring
284/// population crosses to the device.
285#[derive(Debug, Clone)]
286pub struct CmaEsState<B: Backend> {
287    /// Distribution mean `m`, length `D`.
288    mean: Vec<f32>,
289    /// Covariance matrix `C`, row-major `D × D`.
290    cov: Vec<f32>,
291    /// Conjugate evolution path `p_σ`, length `D`.
292    p_sigma: Vec<f32>,
293    /// Anisotropic evolution path `p_c`, length `D`.
294    p_c: Vec<f32>,
295    /// Global step size `σ`.
296    sigma: f32,
297    /// Completed-generation counter.
298    generation: usize,
299    /// Best-so-far genome, shape `(1, D)`.
300    best_genome: Option<Tensor<B, 2>>,
301    /// Best-so-far fitness (canonical maximise convention).
302    best_fitness: f32,
303    /// Cached symmetric eigendecomposition of the **current** `cov`.
304    ///
305    /// The eigendecomposition is the most expensive host op per generation and
306    /// is needed twice on an unchanged `C`: [`Strategy::ask`] builds the
307    /// sampling transform `B·diag(√Λ)` from it, and the following
308    /// [`Strategy::tell`] builds the conditioning matrix `C^{-1/2}` from the
309    /// same decomposition. This field memoizes the raw
310    /// [`SymEigen`](crate::ops::linalg::SymEigen) so `tell` reuses `ask`'s work.
311    ///
312    /// # Invariant
313    ///
314    /// This is a **pure memo** of the decomposition of the `cov` field as it
315    /// stands *right now* — never an independent source of truth. Two rules keep
316    /// it coherent:
317    ///
318    /// - **Any code path that writes `cov` must first clear or take this memo**
319    ///   (set it to `None`, or `take()` it), so a stale decomposition of a
320    ///   superseded `C` can never be read back.
321    /// - **`ask` produces, never trusts.** It unconditionally recomputes the
322    ///   decomposition of the current `cov` and *overwrites* this field with the
323    ///   fresh result; it never reads the prior memo. `tell` is the sole
324    ///   consumer — it `take()`s the memo (falling back to a fresh
325    ///   `jacobi_eigen` if a state skipped `ask`).
326    ///
327    /// Because `jacobi_eigen` is deterministic, reusing the memo is bit-identical
328    /// to recomputing it, so the cache is transparent to same-seed determinism.
329    eig: Option<SymEigen>,
330}
331
332impl<B: Backend> CmaEsState<B> {
333    /// Assembles a CMA-ES state, checking the distribution parameters are
334    /// dimensionally consistent.
335    ///
336    /// # Errors
337    ///
338    /// Returns a [`ConfigError`] if `mean` is empty, if `cov` is not `D × D`
339    /// row-major (`D = mean.len()`), if `p_sigma` or `p_c` differs from `D`,
340    /// or if `sigma` is not strictly positive and finite.
341    #[allow(clippy::too_many_arguments)]
342    pub fn try_new(
343        mean: Vec<f32>,
344        mut cov: Vec<f32>,
345        p_sigma: Vec<f32>,
346        p_c: Vec<f32>,
347        sigma: f32,
348        generation: usize,
349        best_genome: Option<Tensor<B, 2>>,
350        best_fitness: f32,
351    ) -> Result<Self, ConfigError> {
352        let d = mean.len();
353        config::nonzero("CmaEsState", "mean", d)?;
354        if cov.len() != d * d {
355            return Err(ConfigError {
356                config: "CmaEsState",
357                field: "cov",
358                kind: ConstraintKind::Custom("covariance must be a row-major D × D matrix"),
359            });
360        }
361        if p_sigma.len() != d {
362            return Err(ConfigError {
363                config: "CmaEsState",
364                field: "p_sigma",
365                kind: ConstraintKind::Custom("evolution path length must equal D"),
366            });
367        }
368        if p_c.len() != d {
369            return Err(ConfigError {
370                config: "CmaEsState",
371                field: "p_c",
372                kind: ConstraintKind::Custom("evolution path length must equal D"),
373            });
374        }
375        config::positive("CmaEsState", "sigma", f64::from(sigma))?;
376        // Normalize a caller-supplied `cov` to exact symmetry: the
377        // eigendecomposition the strategy runs on `C` assumes symmetry. The
378        // in-loop rank-1 / rank-μ updates preserve symmetry only up to
379        // floating-point rounding (a few ULPs): the rank-μ accumulation forms
380        // `(w · yi[i]) · yi[j]` for the (i,j) entry but `(w · yi[j]) · yi[i]`
381        // for its transpose, which are equal under commutativity but *not*
382        // associativity, so the two triangle entries can diverge slightly (see
383        // the `cma_es_drive_preserves_invariants` property test's rationale).
384        // This `try_new` symmetrization still averages caller-supplied triangles
385        // (pycma-style) — better than a tolerance-based rejection, mirroring the
386        // sanitize-at-the-chokepoint convention of ADR 0034 rather than pushing
387        // the problem back onto the caller.
388        symmetrize(&mut cov, d);
389        Ok(Self {
390            mean,
391            cov,
392            p_sigma,
393            p_c,
394            sigma,
395            generation,
396            best_genome,
397            best_fitness,
398            // Internal cache state, not caller-suppliable: a freshly
399            // constructed state has no decomposition memoized yet; the first
400            // `ask` produces one.
401            eig: None,
402        })
403    }
404
405    /// Distribution mean `m`, length `D`.
406    #[must_use]
407    pub fn mean(&self) -> &[f32] {
408        &self.mean
409    }
410
411    /// Covariance matrix `C`, row-major `D × D`.
412    #[must_use]
413    pub fn cov(&self) -> &[f32] {
414        &self.cov
415    }
416
417    /// Conjugate evolution path `p_σ`, length `D`.
418    #[must_use]
419    pub fn p_sigma(&self) -> &[f32] {
420        &self.p_sigma
421    }
422
423    /// Anisotropic evolution path `p_c`, length `D`.
424    #[must_use]
425    pub fn p_c(&self) -> &[f32] {
426        &self.p_c
427    }
428
429    /// Global step size `σ`.
430    #[must_use]
431    pub fn sigma(&self) -> f32 {
432        self.sigma
433    }
434
435    /// Completed-generation counter.
436    #[must_use]
437    pub fn generation(&self) -> usize {
438        self.generation
439    }
440
441    /// Best-so-far genome (shape `(1, D)`), or `None` before the first `tell`.
442    #[must_use]
443    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
444        self.best_genome.as_ref()
445    }
446
447    /// Best-so-far (canonical, maximise) fitness.
448    #[must_use]
449    pub fn best_fitness(&self) -> f32 {
450        self.best_fitness
451    }
452}
453
454/// Covariance Matrix Adaptation Evolution Strategy.
455///
456/// # Example
457///
458/// ```no_run
459/// use burn::backend::Flex;
460/// use rlevo_evolution::algorithms::cma_es::{CmaEsConfig, CmaEs};
461///
462/// let strategy = CmaEs::<Flex>::new();
463/// let params = CmaEsConfig::default_for(10);
464/// let _ = (strategy, params);
465/// ```
466#[derive(Debug, Clone, Copy, Default)]
467pub struct CmaEs<B: Backend> {
468    _backend: PhantomData<fn() -> B>,
469}
470
471impl<B: Backend> CmaEs<B> {
472    /// Builds a new (stateless) strategy object.
473    #[must_use]
474    pub fn new() -> Self {
475        Self {
476            _backend: PhantomData,
477        }
478    }
479}
480
481impl<B: Backend> Strategy<B> for CmaEs<B>
482where
483    B::Device: Clone,
484{
485    type Params = CmaEsConfig;
486    type State = CmaEsState<B>;
487    type Genome = Tensor<B, 2>;
488
489    /// Initializes `m⁰` uniformly in `params.bounds` (host-RNG convention),
490    /// `C = I`, `σ = initial_sigma`, and both evolution paths to zero.
491    fn init(
492        &self,
493        params: &CmaEsConfig,
494        rng: &mut dyn Rng,
495        _device: &<B as burn::tensor::backend::BackendTypes>::Device,
496    ) -> CmaEsState<B> {
497        debug_assert!(
498            params.validate().is_ok(),
499            "invalid CmaEsConfig reached init: {params:?}"
500        );
501        let d = params.genome_dim;
502        let (lo, hi): (f32, f32) = params.bounds.into();
503        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
504        let mean: Vec<f32> = (0..d)
505            .map(|_| lo + (hi - lo) * stream.random::<f32>())
506            .collect();
507        let mut cov: Vec<f32> = vec![0.0; d * d];
508        for i in 0..d {
509            cov[i * d + i] = 1.0;
510        }
511        CmaEsState {
512            mean,
513            cov,
514            p_sigma: vec![0.0; d],
515            p_c: vec![0.0; d],
516            sigma: params.initial_sigma,
517            generation: 0,
518            best_genome: None,
519            best_fitness: f32::NEG_INFINITY,
520            // No decomposition memoized yet; the first `ask` produces one.
521            eig: None,
522        }
523    }
524
525    /// Samples `λ` offspring from `N(m, σ²C)`.
526    ///
527    /// The covariance is eigendecomposed into `C = B diag(Λ) Bᵀ`; each
528    /// offspring is `xᵢ = m + σ · B diag(√Λ) zᵢ` for `zᵢ ~ N(0, I)`, drawn
529    /// host-side from a deterministic [`SeedPurpose::CmaSampling`] stream. The
530    /// distribution parameters are returned unchanged (the mean/covariance
531    /// update happens in [`tell`](Self::tell), which recomputes the steps from
532    /// the population).
533    ///
534    /// The one thing `ask` *does* mutate on the returned state is the
535    /// eigendecomposition memo (`CmaEsState::eig`): it stores the fresh
536    /// decomposition of the current `C` so the paired `tell` reuses it to build
537    /// `C^{-1/2}` instead of decomposing the same unchanged matrix a second
538    /// time. `ask` produces the memo and never trusts a prior one.
539    fn ask(
540        &self,
541        params: &CmaEsConfig,
542        state: &CmaEsState<B>,
543        rng: &mut dyn Rng,
544        device: &<B as burn::tensor::backend::BackendTypes>::Device,
545    ) -> (Tensor<B, 2>, CmaEsState<B>) {
546        let d = params.genome_dim;
547        let lambda = params.pop_size;
548
549        // Sampling transform B·diag(√Λ) from the eigendecomposition of C. The
550        // raw decomposition is kept whole (not destructured) so it can be
551        // memoized on the returned state for `tell` to reuse. The eigenvalue
552        // floor is applied *here* per-use — `ask` needs `√Λ`, `tell` needs
553        // `1/√Λ`, so only the raw values are cached and each site floors them.
554        let eig: SymEigen = jacobi_eigen(&state.cov, d);
555        let floor: f32 = eigenvalue_floor(&eig.values);
556        let mut bd: Vec<f32> = vec![0.0; d * d];
557        for i in 0..d {
558            for k in 0..d {
559                bd[i * d + k] = eig.vectors[i * d + k] * eig.values[k].max(floor).sqrt();
560            }
561        }
562
563        let mut stream = seed_stream(
564            rng.next_u64(),
565            state.generation as u64,
566            SeedPurpose::CmaSampling,
567        );
568        let mut rows: Vec<f32> = Vec::with_capacity(lambda * d);
569        for _ in 0..lambda {
570            let z: Vec<f32> = (0..d)
571                .map(|_| crate::sampling::standard_normal(&mut stream))
572                .collect();
573            let bdz: Vec<f32> = matvec(&bd, &z, d);
574            for (mean_i, bdz_i) in state.mean.iter().zip(bdz.iter()) {
575                rows.push(mean_i + state.sigma * bdz_i);
576            }
577        }
578        let population = Tensor::<B, 2>::from_data(TensorData::new(rows, [lambda, d]), device);
579        // Clone first, then overwrite the memo on the clone: the decomposition
580        // just built is exactly the decomposition of this state's (unchanged)
581        // `cov`, so it is a valid memo for the paired `tell` to consume.
582        let mut next = state.clone();
583        next.eig = Some(eig);
584        (population, next)
585    }
586
587    /// Ranks the offspring, recombines the mean, and runs CSA + the rank-1 /
588    /// rank-μ covariance updates.
589    ///
590    /// # Lost generations
591    ///
592    /// The rank-μ update needs `μ` *usable* selection steps. Ranking already
593    /// sanitizes (`NaN → −∞`) and sorts with `total_cmp`, so a non-finite
594    /// fitness can never rank among the best — but if **fewer than `μ`**
595    /// sanitized values are finite, non-usable individuals would still fill out
596    /// the selected `μ` and feed meaningless steps `yᵢ = (xᵢ − m)/σ` into the
597    /// mean and covariance updates. When that happens `tell` takes a deliberate
598    /// **lost generation**: the entire adaptive update (mean, `C`, `p_σ`, `p_c`,
599    /// `σ`, and the eigendecomposition memo) is skipped and the search
600    /// distribution is left exactly unchanged. A legitimate `−∞` counts as
601    /// non-usable here — it marks a member evaluation that broke, so it cannot
602    /// contribute a meaningful recombination step.
603    ///
604    /// A lost generation still **advances the generation counter and updates
605    /// best-so-far tracking**. Advancing the counter matters for determinism:
606    /// the per-generation sampling stream is keyed on
607    /// `seed_stream(_, generation, _)`, so bumping it ensures the next `ask`
608    /// draws a *fresh* offspring batch rather than replaying the identical draw
609    /// that just failed. The retained eigendecomposition memo stays coherent
610    /// because `cov` is untouched.
611    #[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
612    fn tell(
613        &self,
614        params: &CmaEsConfig,
615        population: Tensor<B, 2>,
616        fitness: Tensor<B, 1>,
617        mut state: CmaEsState<B>,
618        _rng: &mut dyn Rng,
619    ) -> (CmaEsState<B>, StrategyMetrics) {
620        let d = params.genome_dim;
621        let lambda = params.pop_size;
622        let mu = params.mu;
623
624        // Best-tracking (`update_best`, below) reads this raw fitness directly
625        // and relies on the harness-side sanitize chokepoint (ADR 0034) to have
626        // already mapped `+∞ → f32::MAX` before `tell`; that `+∞` hygiene is
627        // pre-existing and out of scope here. The adaptive update below reads
628        // only the locally-sanitized `sane` copy.
629        let fitness_host: Vec<f32> = fitness
630            .into_data()
631            .into_vec::<f32>()
632            .expect("fitness tensor must be readable as f32");
633        let pop_host: Vec<f32> = population
634            .clone()
635            .into_data()
636            .into_vec::<f32>()
637            .expect("population tensor must be readable as f32");
638
639        // Rank offspring descending (canonical maximise): ranked[0] is the
640        // best (highest fitness). The recombination weights `params.weights`
641        // are assigned to rank positions unchanged — only the ordering of
642        // which individuals occupy those ranks inverts relative to a
643        // minimisation engine. Against a `Minimize` landscape the harness
644        // feeds the engine `−cost`, so this descending canonical order
645        // matches the `pycma` ascending-cost order point-for-point.
646        let mut ranked: Vec<usize> = (0..lambda).collect();
647        // Sanitize NaN → −inf (worst) so it can never rank as best, then order
648        // by `total_cmp` (deterministic; sanitized NaN sorts last).
649        let sane: Vec<f32> = fitness_host
650            .iter()
651            .map(|&f| crate::fitness::sanitize_fitness(f))
652            .collect();
653
654        // Lost-generation guard: the rank-μ update needs μ *usable* (finite)
655        // steps. If fewer than μ sanitized values are finite, the selected μ
656        // would include non-usable members (`−∞`, a sanitized `NaN`, or a
657        // broken `−∞` evaluation) whose steps corrupt the mean/covariance
658        // update. Freeze the whole search distribution — mean, `C`, `p_σ`,
659        // `p_c`, `σ`, and the eig memo all stay untouched (the retained memo
660        // remains coherent because `cov` is unchanged) — but still advance the
661        // generation counter (so the next `ask` draws a fresh stream, not a
662        // replay) and best-so-far tracking. See the `# Lost generations` doc
663        // section above.
664        let n_finite: usize = sane.iter().filter(|f| f.is_finite()).count();
665        if n_finite < mu {
666            update_best(&mut state, &population, &fitness_host);
667            state.generation += 1;
668            let metrics = StrategyMetrics::from_host_fitness(
669                state.generation,
670                &fitness_host,
671                state.best_fitness,
672            );
673            state.best_fitness = metrics.best_fitness_ever();
674            return (state, metrics);
675        }
676
677        ranked.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
678
679        let m_old: Vec<f32> = state.mean.clone();
680        let sigma_old: f32 = state.sigma;
681
682        // Selection steps yᵢ = (x_{(i)} − m) / σ for the μ best, plus the
683        // recombination y_w = Σ wᵢ y_{(i)}.
684        let mut y_sel: Vec<Vec<f32>> = Vec::with_capacity(mu);
685        let mut y_w: Vec<f32> = vec![0.0; d];
686        for (&idx, &w) in ranked.iter().take(mu).zip(params.weights.iter()) {
687            let mut yi: Vec<f32> = vec![0.0; d];
688            for i in 0..d {
689                yi[i] = (pop_host[idx * d + i] - m_old[i]) / sigma_old;
690                y_w[i] += w * yi[i];
691            }
692            y_sel.push(yi);
693        }
694
695        // New mean: m ← m + σ · y_w (cₘ = 1).
696        let mut mean_new: Vec<f32> = vec![0.0; d];
697        for i in 0..d {
698            mean_new[i] = m_old[i] + sigma_old * y_w[i];
699        }
700
701        // C^{-1/2} = B diag(1/√Λ) Bᵀ from the eigendecomposition of the old C.
702        // Reuse the memo `ask` stored for this exact (unchanged) `C`; `take()`
703        // it so the stale decomposition cannot outlive the `cov` overwrite at
704        // the end of this method. The fallback keeps `tell` correct for a state
705        // that reached here without a paired `ask`. The floor is applied here
706        // as `1/√Λ` (vs `ask`'s `√Λ`), so only the raw eigenvalues are cached.
707        let SymEigen {
708            values: eigvals,
709            vectors: eigvecs,
710        } = state
711            .eig
712            .take()
713            .unwrap_or_else(|| jacobi_eigen(&state.cov, d));
714        let floor: f32 = eigenvalue_floor(&eigvals);
715        let inv_sqrt: Vec<f32> = eigvals.iter().map(|&l| 1.0 / l.max(floor).sqrt()).collect();
716        let mut c_inv_sqrt: Vec<f32> = vec![0.0; d * d];
717        for i in 0..d {
718            for j in 0..d {
719                let mut acc: f32 = 0.0;
720                for k in 0..d {
721                    acc += eigvecs[i * d + k] * inv_sqrt[k] * eigvecs[j * d + k];
722                }
723                c_inv_sqrt[i * d + j] = acc;
724            }
725        }
726
727        // Conjugate path: p_σ ← (1−c_σ) p_σ + √(c_σ(2−c_σ)μ_eff) · C^{-1/2} y_w.
728        let cs_factor: f32 = (params.c_sigma * (2.0 - params.c_sigma) * params.mu_eff).sqrt();
729        let c_inv_yw: Vec<f32> = matvec(&c_inv_sqrt, &y_w, d);
730        let mut p_sigma: Vec<f32> = vec![0.0; d];
731        for i in 0..d {
732            p_sigma[i] = (1.0 - params.c_sigma) * state.p_sigma[i] + cs_factor * c_inv_yw[i];
733        }
734        let p_sigma_norm: f32 = p_sigma.iter().map(|v| v * v).sum::<f32>().sqrt();
735
736        // CSA step-size update: σ ← σ · exp((c_σ/d_σ)(‖p_σ‖/χ_n − 1)). Floor at
737        // the smallest positive f32 so a collapsing σ can never reach exactly
738        // zero (which would make next generation's yᵢ = (xᵢ − m)/σ a 0/0 NaN).
739        let sigma_new: f32 = (sigma_old
740            * ((params.c_sigma / params.d_sigma) * (p_sigma_norm / params.chi_n - 1.0)).exp())
741        .max(f32::MIN_POSITIVE);
742
743        // Heaviside stall guard hσ on the anisotropic path.
744        let gen_count: f32 = state.generation as f32 + 1.0;
745        let denom: f32 = (1.0 - (1.0 - params.c_sigma).powf(2.0 * gen_count)).sqrt();
746        let h_sigma: f32 = if p_sigma_norm / denom
747            < (1.4 + 2.0 / (params.genome_dim as f32 + 1.0)) * params.chi_n
748        {
749            1.0
750        } else {
751            0.0
752        };
753
754        // Anisotropic path: p_c ← (1−c_c) p_c + hσ √(c_c(2−c_c)μ_eff) y_w.
755        let pc_factor: f32 = (params.c_c * (2.0 - params.c_c) * params.mu_eff).sqrt();
756        let mut p_c: Vec<f32> = vec![0.0; d];
757        for i in 0..d {
758            p_c[i] = (1.0 - params.c_c) * state.p_c[i] + h_sigma * pc_factor * y_w[i];
759        }
760
761        // Covariance update: rank-1 (p_c) + rank-μ (selected steps).
762        // δ(hσ) keeps E[C] unbiased when the rank-1 term is stalled.
763        let delta_h: f32 = (1.0 - h_sigma) * params.c_c * (2.0 - params.c_c);
764        let c_old: Vec<f32> = state.cov.clone();
765        let mut cov_new: Vec<f32> = vec![0.0; d * d];
766        for i in 0..d {
767            for j in 0..d {
768                let decay: f32 = 1.0 - params.c_1 - params.c_mu;
769                let rank1: f32 = params.c_1 * (p_c[i] * p_c[j] + delta_h * c_old[i * d + j]);
770                let mut rankmu: f32 = 0.0;
771                for (rank, yi) in y_sel.iter().enumerate() {
772                    // Factor the bare outer-product term `yi[i] * yi[j]` before
773                    // scaling by the per-rank weight: this makes each (i,j) and
774                    // (j,i) contribution bit-identical (float multiply is
775                    // commutative but not associative), so `C` is symmetric by
776                    // construction rather than only up to a few ULPs.
777                    rankmu += params.weights[rank] * (yi[i] * yi[j]);
778                }
779                rankmu *= params.c_mu;
780                cov_new[i * d + j] = decay * c_old[i * d + j] + rank1 + rankmu;
781            }
782        }
783        // Defensive float-drift hygiene (pycma-style): with the factored-product
784        // accumulation above, `C` is already bit-exact symmetric by construction,
785        // so this re-symmetrization is a no-op today. It is a backstop guarding
786        // the solver's symmetry assumption (ask's `√Λ` sampling, tell's `C^{-1/2}`
787        // conditioning) against any future edit that reorders the accumulation.
788        symmetrize(&mut cov_new, d);
789
790        // Track the best individual this generation.
791        update_best(&mut state, &population, &fitness_host);
792
793        state.generation += 1;
794        let metrics =
795            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
796        state.best_fitness = metrics.best_fitness_ever();
797
798        state.mean = mean_new;
799        // Overwrites `cov`; the eig memo was already `take()`n above, so there
800        // is no stale-decomposition hazard — `state.eig` is `None` on return
801        // and the next `ask` will produce a fresh memo for this new `C`.
802        state.cov = cov_new;
803        state.p_sigma = p_sigma;
804        state.p_c = p_c;
805        state.sigma = sigma_new;
806
807        (state, metrics)
808    }
809
810    /// Returns the best-so-far genome and its fitness, or `None` before the
811    /// first [`tell`](Self::tell) call.
812    fn best(&self, state: &CmaEsState<B>) -> Option<(Tensor<B, 2>, f32)> {
813        state
814            .best_genome
815            .as_ref()
816            .map(|g| (g.clone(), state.best_fitness))
817    }
818}
819
820/// Updates `state.best_genome` / `state.best_fitness` if this generation
821/// improved on the best-so-far.
822fn update_best<B: Backend>(state: &mut CmaEsState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
823    if fitness.is_empty() {
824        return;
825    }
826    let mut best_idx: usize = 0;
827    let mut best: f32 = f32::NEG_INFINITY;
828    for (i, &f) in fitness.iter().enumerate() {
829        if f > best {
830            best = f;
831            best_idx = i;
832        }
833    }
834    if best > state.best_fitness {
835        let device = pop.device();
836        #[allow(clippy::cast_possible_wrap)]
837        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
838            TensorData::new(vec![best_idx as i64], [1]),
839            &device,
840        );
841        state.best_genome = Some(pop.clone().select(0, idx));
842        state.best_fitness = best;
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use burn::backend::Flex;
850    use proptest::prelude::*;
851    use rand::SeedableRng;
852    use rand::rngs::StdRng;
853
854    #[test]
855    fn try_new_checks_dimensions() {
856        // D = 2: cov is 2×2 = 4 entries, both paths length 2, σ > 0.
857        assert!(
858            CmaEsState::<Flex>::try_new(
859                vec![0.0, 0.0],
860                vec![1.0, 0.0, 0.0, 1.0],
861                vec![0.0, 0.0],
862                vec![0.0, 0.0],
863                0.5,
864                0,
865                None,
866                f32::MIN,
867            )
868            .is_ok()
869        );
870        // cov length 3 ≠ D·D.
871        assert!(
872            CmaEsState::<Flex>::try_new(
873                vec![0.0, 0.0],
874                vec![1.0, 0.0, 0.0],
875                vec![0.0, 0.0],
876                vec![0.0, 0.0],
877                0.5,
878                0,
879                None,
880                f32::MIN,
881            )
882            .is_err()
883        );
884        // Non-positive σ.
885        assert!(
886            CmaEsState::<Flex>::try_new(
887                vec![0.0, 0.0],
888                vec![1.0, 0.0, 0.0, 1.0],
889                vec![0.0, 0.0],
890                vec![0.0, 0.0],
891                0.0,
892                0,
893                None,
894                f32::MIN,
895            )
896            .is_err()
897        );
898    }
899
900    #[test]
901    fn default_config_validates() {
902        assert!(CmaEsConfig::default_for(10).validate().is_ok());
903    }
904
905    #[test]
906    fn rejects_pop_size_below_two() {
907        let mut cfg = CmaEsConfig::default_for(10);
908        cfg.pop_size = 1;
909        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
910    }
911
912    #[test]
913    fn default_config_validates_all() {
914        assert!(CmaEsConfig::default_for(10).validate_all().is_ok());
915    }
916
917    #[test]
918    fn rejects_desynced_weights() {
919        // A hand-built literal that dropped a weight: length no longer equals μ
920        // and the remaining weights no longer sum to 1.
921        let mut cfg = CmaEsConfig::default_for(10);
922        cfg.weights.pop();
923        let err = cfg.validate().unwrap_err();
924        assert_eq!(err.field, "weights");
925    }
926
927    #[test]
928    fn rejects_diverging_covariance_rates() {
929        let mut cfg = CmaEsConfig::default_for(10);
930        // c_1 + c_mu > 1 makes the rank-update retention factor negative.
931        cfg.c_1 = 0.7;
932        cfg.c_mu = 0.7;
933        let err = cfg.validate().unwrap_err();
934        assert_eq!(err.field, "c_1_plus_c_mu");
935    }
936
937    #[test]
938    fn validate_all_reports_every_violation() {
939        // Desync three independent derived fields at once; fail-fast would hide
940        // all but the first, validate_all surfaces them together.
941        let mut cfg = CmaEsConfig::default_for(10);
942        cfg.weights.pop(); // weights length + sum
943        cfg.d_sigma = -1.0; // non-positive damping
944        cfg.c_1 = 0.7;
945        cfg.c_mu = 0.7; // c_1 + c_mu > 1
946        let errs = cfg.validate_all().unwrap_err();
947        let fields: Vec<&str> = errs.iter().map(|e| e.field).collect();
948        assert!(fields.contains(&"weights"));
949        assert!(fields.contains(&"d_sigma"));
950        assert!(fields.contains(&"c_1_plus_c_mu"));
951        assert!(errs.len() >= 3, "expected all violations, got {fields:?}");
952        // validate() stays consistent — it is the first of these.
953        assert_eq!(cfg.validate().unwrap_err(), errs[0]);
954    }
955
956    #[test]
957    fn default_for_d10_constants() {
958        // Hansen 2016 Table 1 reference values for D = 10.
959        let cfg = CmaEsConfig::default_for(10);
960        // λ = 4 + ⌊3 ln 10⌋ = 4 + ⌊6.907⌋ = 10; μ = 5.
961        assert_eq!(cfg.pop_size, 10);
962        assert_eq!(cfg.mu, 5);
963        assert_eq!(cfg.weights.len(), 5);
964        // Weights are positive, descending, and normalized.
965        let sum: f32 = cfg.weights.iter().sum();
966        approx::assert_relative_eq!(sum, 1.0, epsilon = 1e-5);
967        for pair in cfg.weights.windows(2) {
968            assert!(pair[0] >= pair[1], "weights must be descending");
969        }
970        // μ_eff lies in (1, μ].
971        assert!(
972            cfg.mu_eff > 1.0 && cfg.mu_eff <= 5.0,
973            "mu_eff = {}",
974            cfg.mu_eff
975        );
976        // Learning rates are in their valid ranges.
977        assert!(cfg.c_sigma > 0.0 && cfg.c_sigma < 1.0);
978        assert!(cfg.d_sigma >= 1.0);
979        assert!(cfg.c_c > 0.0 && cfg.c_c < 1.0);
980        assert!(cfg.c_1 > 0.0 && cfg.c_1 < 1.0);
981        assert!(cfg.c_mu > 0.0);
982        assert!(cfg.c_1 + cfg.c_mu <= 1.0, "c_1 + c_mu must not exceed 1");
983        // χ_n = √10·(1 − 1/40 + 1/2100) ≈ 3.0847 (just below √10 ≈ 3.162).
984        approx::assert_relative_eq!(cfg.chi_n, 3.084_7_f32, epsilon = 1e-3);
985    }
986
987    #[test]
988    fn with_pop_size_scales_mu() {
989        let cfg = CmaEsConfig::with_pop_size(50, 10);
990        assert_eq!(cfg.pop_size, 50);
991        assert_eq!(cfg.mu, 25);
992        let sum: f32 = cfg.weights.iter().sum();
993        approx::assert_relative_eq!(sum, 1.0, epsilon = 1e-5);
994    }
995
996    /// Lost generation: with fewer than μ finite fitness values, `tell` must
997    /// freeze the entire search distribution (mean, `C`, `σ`, both paths) yet
998    /// still advance the generation counter and best-so-far tracking.
999    #[test]
1000    fn tell_freezes_distribution_on_too_few_finite() {
1001        let strategy = CmaEs::<Flex>::new();
1002        let params = CmaEsConfig::with_pop_size(6, 2); // μ = 3.
1003        assert_eq!(params.mu, 3);
1004        let device = Default::default();
1005        let mut rng = StdRng::seed_from_u64(0xF10E);
1006
1007        let state = strategy.init(&params, &mut rng, &device);
1008        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1009
1010        // Snapshot the pre-`tell` distribution (all bit-exact).
1011        let mean0: Vec<f32> = asked.mean().to_vec();
1012        let cov0: Vec<f32> = asked.cov().to_vec();
1013        let p_sigma0: Vec<f32> = asked.p_sigma().to_vec();
1014        let p_c0: Vec<f32> = asked.p_c().to_vec();
1015        let sigma0: f32 = asked.sigma();
1016        let gen0: usize = asked.generation();
1017
1018        // Only one finite value; μ = 3 → lost generation.
1019        let fitness = Tensor::<Flex, 1>::from_data(
1020            TensorData::new(
1021                vec![1.0f32, f32::NAN, f32::NAN, f32::NAN, f32::NAN, f32::NAN],
1022                [6],
1023            ),
1024            &device,
1025        );
1026        let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
1027
1028        // Distribution frozen, bit-for-bit.
1029        assert_eq!(told.mean(), mean0.as_slice());
1030        assert_eq!(told.cov(), cov0.as_slice());
1031        assert_eq!(told.p_sigma(), p_sigma0.as_slice());
1032        assert_eq!(told.p_c(), p_c0.as_slice());
1033        assert_eq!(told.sigma().to_bits(), sigma0.to_bits());
1034        // Counter advanced; best tracked from the single finite value.
1035        assert_eq!(told.generation(), gen0 + 1);
1036        assert_eq!(told.best_fitness().to_bits(), 1.0f32.to_bits());
1037    }
1038
1039    /// Cache coherence: `tell` reusing the eigendecomposition memo `ask` stored
1040    /// produces a state bit-identical to `tell` on an equivalent state whose
1041    /// memo is absent (rebuilt via `try_new`, which recomputes the
1042    /// decomposition). `jacobi_eigen` is deterministic, so the two must agree.
1043    #[test]
1044    fn tell_cache_reuse_matches_recompute() {
1045        let strategy = CmaEs::<Flex>::new();
1046        let params = CmaEsConfig::with_pop_size(6, 2);
1047        let device = Default::default();
1048        let mut rng = StdRng::seed_from_u64(0x00CA_C4E5);
1049
1050        let state = strategy.init(&params, &mut rng, &device);
1051        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1052
1053        // Rebuild an equivalent state from the asked-state accessors. `try_new`
1054        // never populates the memo, so its `tell` recomputes the decomposition.
1055        let rebuilt = CmaEsState::<Flex>::try_new(
1056            asked.mean().to_vec(),
1057            asked.cov().to_vec(),
1058            asked.p_sigma().to_vec(),
1059            asked.p_c().to_vec(),
1060            asked.sigma(),
1061            asked.generation(),
1062            asked.best_genome().cloned(),
1063            asked.best_fitness(),
1064        )
1065        .expect("valid state");
1066
1067        // Identical fitness (≥ μ finite → full adaptive update runs).
1068        let fitness_vals: Vec<f32> = vec![6.0, 5.0, 4.0, 3.0, 2.0, 1.0];
1069        let f_cached =
1070            Tensor::<Flex, 1>::from_data(TensorData::new(fitness_vals.clone(), [6]), &device);
1071        let f_recomp = Tensor::<Flex, 1>::from_data(TensorData::new(fitness_vals, [6]), &device);
1072
1073        // `tell` ignores its `_rng`; fresh RNGs are only for the signature.
1074        let mut rng_a = StdRng::seed_from_u64(1);
1075        let mut rng_b = StdRng::seed_from_u64(2);
1076        let (told_cached, _) =
1077            strategy.tell(&params, population.clone(), f_cached, asked, &mut rng_a);
1078        let (told_recomp, _) = strategy.tell(&params, population, f_recomp, rebuilt, &mut rng_b);
1079
1080        assert_eq!(told_cached.mean(), told_recomp.mean());
1081        assert_eq!(told_cached.cov(), told_recomp.cov());
1082        assert_eq!(told_cached.p_sigma(), told_recomp.p_sigma());
1083        assert_eq!(told_cached.p_c(), told_recomp.p_c());
1084        assert_eq!(told_cached.sigma().to_bits(), told_recomp.sigma().to_bits());
1085    }
1086
1087    /// `try_new` normalizes a caller-supplied asymmetric covariance to exact
1088    /// symmetry by averaging the triangles (pycma-style construction boundary).
1089    #[test]
1090    fn try_new_symmetrizes_covariance() {
1091        // Off-diagonals (0,1) = 0.4 and (1,0) = 0.2 → both become 0.3.
1092        let state = CmaEsState::<Flex>::try_new(
1093            vec![0.0, 0.0],
1094            vec![1.0, 0.4, 0.2, 1.0],
1095            vec![0.0, 0.0],
1096            vec![0.0, 0.0],
1097            0.5,
1098            0,
1099            None,
1100            f32::NEG_INFINITY,
1101        )
1102        .expect("valid state");
1103        let cov: &[f32] = state.cov();
1104        approx::assert_relative_eq!(cov[1], 0.3, epsilon = 1e-6);
1105        approx::assert_relative_eq!(cov[2], 0.3, epsilon = 1e-6);
1106    }
1107
1108    /// Memo-hygiene: a full adaptive `tell` overwrites `cov`, so it must leave
1109    /// the eigendecomposition memo empty. Locks the "any `cov` write clears the
1110    /// memo" invariant against a future refactor that adds a second
1111    /// cov-mutation path but forgets to `take()`/clear `eig`.
1112    #[test]
1113    fn tell_clears_eig_memo_after_cov_update() {
1114        let strategy = CmaEs::<Flex>::new();
1115        let params = CmaEsConfig::with_pop_size(6, 2);
1116        let device = Default::default();
1117        let mut rng = StdRng::seed_from_u64(0x00EE_6011);
1118
1119        let state = strategy.init(&params, &mut rng, &device);
1120        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1121        // `ask` produced a memo for this state.
1122        assert!(asked.eig.is_some(), "ask must populate the eig memo");
1123
1124        // ≥ μ finite → full adaptive update runs and overwrites `cov`.
1125        let fitness = Tensor::<Flex, 1>::from_data(
1126            TensorData::new(vec![6.0f32, 5.0, 4.0, 3.0, 2.0, 1.0], [6]),
1127            &device,
1128        );
1129        let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
1130
1131        assert!(
1132            told.eig.is_none(),
1133            "a cov-mutating tell must leave the eig memo empty"
1134        );
1135    }
1136
1137    /// Two-generation sequential drive: init → ask → tell → ask → tell. The
1138    /// second `ask` must produce a *fresh* memo (of the first `tell`'s new `C`),
1139    /// and the second `tell` must consume it and leave the search distribution
1140    /// finite.
1141    #[test]
1142    fn two_generation_sequence_refreshes_memo() {
1143        let strategy = CmaEs::<Flex>::new();
1144        let params = CmaEsConfig::with_pop_size(6, 2);
1145        let device = Default::default();
1146        let mut rng = StdRng::seed_from_u64(0x00A2_9E11);
1147
1148        let fitness = |dev: &_| {
1149            Tensor::<Flex, 1>::from_data(
1150                TensorData::new(vec![6.0f32, 5.0, 4.0, 3.0, 2.0, 1.0], [6]),
1151                dev,
1152            )
1153        };
1154
1155        // Generation 1.
1156        let s0 = strategy.init(&params, &mut rng, &device);
1157        let (pop0, asked0) = strategy.ask(&params, &s0, &mut rng, &device);
1158        assert!(asked0.eig.is_some(), "first ask must populate the memo");
1159        let (told0, _m0) = strategy.tell(&params, pop0, fitness(&device), asked0, &mut rng);
1160        assert!(told0.eig.is_none(), "first tell must clear the memo");
1161
1162        // Generation 2: a fresh memo of the updated `C`.
1163        let (pop1, asked1) = strategy.ask(&params, &told0, &mut rng, &device);
1164        assert!(
1165            asked1.eig.is_some(),
1166            "second ask must build a fresh memo off the updated cov"
1167        );
1168        let (told1, _m1) = strategy.tell(&params, pop1, fitness(&device), asked1, &mut rng);
1169        assert!(told1.eig.is_none(), "second tell must clear the memo");
1170
1171        // The distribution stayed finite across both generations.
1172        assert!(told1.mean().iter().all(|v| v.is_finite()), "mean finite");
1173        assert!(told1.cov().iter().all(|v| v.is_finite()), "cov finite");
1174        assert!(told1.sigma().is_finite(), "sigma finite");
1175    }
1176
1177    /// Issue #147 §7.2: a full adaptive `tell` must leave `C` symmetric and
1178    /// positive-definite. Since #241 the rank-μ update factors the bare
1179    /// outer-product term before applying the per-rank weight, so each (i,j)
1180    /// and (j,i) contribution is bit-identical, and a `symmetrize` backstop
1181    /// runs after the loop — symmetry is therefore a *structural* guarantee,
1182    /// not a lucky rounding for this seed. The `to_bits()` assertions below are
1183    /// consequently a genuine invariant that holds for every seed/dim; the
1184    /// `cma_es_drive_preserves_invariants` property asserts the same bit-exact
1185    /// equality across the sampled space. PD is checked via a symmetric
1186    /// eigendecomposition (all eigenvalues strictly positive), which is exactly
1187    /// the property `ask`'s `√Λ` sampling and `tell`'s `C^{-1/2}` conditioning
1188    /// rely on.
1189    #[test]
1190    fn tell_keeps_covariance_symmetric_and_positive_definite() {
1191        let strategy = CmaEs::<Flex>::new();
1192        let params = CmaEsConfig::with_pop_size(6, 3);
1193        let d: usize = params.genome_dim;
1194        let device = Default::default();
1195        let mut rng = StdRng::seed_from_u64(0x5EED_C0DE);
1196
1197        let state = strategy.init(&params, &mut rng, &device);
1198        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1199        let fitness = Tensor::<Flex, 1>::from_data(
1200            TensorData::new(vec![6.0f32, 5.0, 4.0, 3.0, 2.0, 1.0], [6]),
1201            &device,
1202        );
1203        let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
1204
1205        let cov: &[f32] = told.cov();
1206        // Symmetric (bit-exact by construction).
1207        for i in 0..d {
1208            for j in 0..d {
1209                assert_eq!(
1210                    cov[i * d + j].to_bits(),
1211                    cov[j * d + i].to_bits(),
1212                    "asymmetry at ({i}, {j})"
1213                );
1214            }
1215        }
1216        // Positive-definite: every eigenvalue strictly positive.
1217        let eig: SymEigen = jacobi_eigen(cov, d);
1218        assert!(
1219            eig.values.iter().all(|&l| l > 0.0),
1220            "covariance not positive-definite: eigenvalues {:?}",
1221            eig.values
1222        );
1223        // Diagonal (the variances) is strictly positive too.
1224        for i in 0..d {
1225            assert!(cov[i * d + i] > 0.0, "non-positive variance at {i}");
1226        }
1227    }
1228
1229    /// Issue #241's open question: does the rank-μ update's ULP asymmetry
1230    /// *compound* over hundreds of generations, drifting `C` off the symmetric
1231    /// manifold the solver assumes? With the #241 fix (factored-product
1232    /// accumulation + `symmetrize` backstop) the answer is structurally no: `C`
1233    /// is bit-exact symmetric after every `tell`, so it never leaves the
1234    /// symmetric manifold and no drift can accumulate — there is nothing to
1235    /// compound. This long run (`λ=16`, `D=5`, 400 generations of synthetic
1236    /// strictly-descending fitness) exercises many `tell` updates and asserts,
1237    /// after *every* generation, bit-exact symmetry across all `(i,j)`/`(j,i)`
1238    /// pairs plus all-finite entries. It protects the fix against a future edit
1239    /// that reorders the accumulation and reintroduces per-generation drift.
1240    #[test]
1241    #[allow(clippy::cast_precision_loss)]
1242    fn long_run_tell_never_drifts_off_symmetric_manifold() {
1243        let strategy = CmaEs::<Flex>::new();
1244        let lambda: usize = 16;
1245        let params = CmaEsConfig::with_pop_size(lambda, 5);
1246        let d: usize = params.genome_dim;
1247        let device = Default::default();
1248        let mut rng = StdRng::seed_from_u64(0x0241_D21F7);
1249
1250        // Synthetic strictly-descending fitness: the point is to drive many
1251        // `tell` updates, not to actually optimize a landscape.
1252        let fitness_vals: Vec<f32> = (0..lambda).map(|i| (lambda - i) as f32).collect();
1253
1254        let mut state = strategy.init(&params, &mut rng, &device);
1255        for generation in 0..400 {
1256            let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1257            let fitness = Tensor::<Flex, 1>::from_data(
1258                TensorData::new(fitness_vals.clone(), [lambda]),
1259                &device,
1260            );
1261            let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
1262
1263            let cov: &[f32] = told.cov();
1264            assert!(
1265                cov.iter().all(|v| v.is_finite()),
1266                "cov non-finite at generation {generation}"
1267            );
1268            for i in 0..d {
1269                for j in 0..d {
1270                    assert_eq!(
1271                        cov[i * d + j].to_bits(),
1272                        cov[j * d + i].to_bits(),
1273                        "asymmetry at ({i}, {j}) in generation {generation}"
1274                    );
1275                }
1276            }
1277            state = told;
1278        }
1279    }
1280
1281    /// Issue #241, isolated: guards the Task-1 accumulation fix on its own.
1282    /// `tell` runs an unconditional `symmetrize` backstop, so every `tell`-level
1283    /// symmetry test would still pass even if the parenthesization were reverted
1284    /// — nothing would independently catch a regressed "bit-exact by
1285    /// construction" claim. This test reconstructs the rank-µ accumulation the
1286    /// way `tell` does but WITHOUT calling `tell`, so no backstop can mask a bad
1287    /// grouping. It uses non-power-of-two floats chosen so the naive grouping
1288    /// actually diverges in the last ULPs:
1289    ///  - (a) the FIXED grouping `w · (yᵢ[i]·yᵢ[j])` is bit-exact symmetric;
1290    ///  - (b) the OLD grouping `(w · yᵢ[i]) · yᵢ[j]` diverges on at least one
1291    ///    transposed pair, documenting why the parenthesization is load-bearing.
1292    #[test]
1293    fn rankmu_accumulation_is_symmetric_by_construction() {
1294        let d: usize = 3;
1295        // Per-rank weights and selected step vectors picked so float
1296        // non-associativity bites (non-power-of-two magnitudes).
1297        let weights: Vec<f32> = vec![0.3, 0.7];
1298        let y_sel: Vec<Vec<f32>> = vec![vec![1.1, 3.3, 7.7], vec![2.2, 5.5, 9.9]];
1299
1300        // (a) FIXED grouping — factor the bare outer product before the weight.
1301        let mut fixed: Vec<f32> = vec![0.0; d * d];
1302        for i in 0..d {
1303            for j in 0..d {
1304                let mut acc: f32 = 0.0;
1305                for (rank, yi) in y_sel.iter().enumerate() {
1306                    acc += weights[rank] * (yi[i] * yi[j]);
1307                }
1308                fixed[i * d + j] = acc;
1309            }
1310        }
1311        for i in 0..d {
1312            for j in 0..d {
1313                assert_eq!(
1314                    fixed[i * d + j].to_bits(),
1315                    fixed[j * d + i].to_bits(),
1316                    "fixed grouping asymmetric at ({i}, {j})"
1317                );
1318            }
1319        }
1320
1321        // (b) OLD grouping — proves the hazard is real: at least one transposed
1322        // pair diverges in its last ULPs without the parenthesization.
1323        let mut old: Vec<f32> = vec![0.0; d * d];
1324        for i in 0..d {
1325            for j in 0..d {
1326                let mut acc: f32 = 0.0;
1327                for (rank, yi) in y_sel.iter().enumerate() {
1328                    acc += weights[rank] * yi[i] * yi[j];
1329                }
1330                old[i * d + j] = acc;
1331            }
1332        }
1333        let mut old_diverges: bool = false;
1334        for i in 0..d {
1335            for j in 0..d {
1336                if old[i * d + j].to_bits() != old[j * d + i].to_bits() {
1337                    old_diverges = true;
1338                }
1339            }
1340        }
1341        assert!(
1342            old_diverges,
1343            "old grouping did not diverge — contrast values no longer exercise \
1344             float non-associativity"
1345        );
1346    }
1347
1348    /// Issue #147 §7.2 best-tracking: `best()` is `None` before any `tell`, and
1349    /// `Some((genome, fitness))` after — reporting the highest-fitness offspring
1350    /// (canonical maximise) with the correct `(1, D)` genome shape.
1351    #[test]
1352    fn best_is_none_before_tell_and_some_after() {
1353        let strategy = CmaEs::<Flex>::new();
1354        let params = CmaEsConfig::with_pop_size(6, 2);
1355        let device = Default::default();
1356        let mut rng = StdRng::seed_from_u64(0xB357_7E57);
1357
1358        let state = strategy.init(&params, &mut rng, &device);
1359        assert!(
1360            strategy.best(&state).is_none(),
1361            "best must be None before the first tell"
1362        );
1363
1364        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1365        let fitness = Tensor::<Flex, 1>::from_data(
1366            TensorData::new(vec![6.0f32, 5.0, 4.0, 3.0, 2.0, 1.0], [6]),
1367            &device,
1368        );
1369        let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
1370
1371        let best = strategy.best(&told).expect("best is Some after a tell");
1372        let (genome, fit): (Tensor<Flex, 2>, f32) = best;
1373        approx::assert_relative_eq!(fit, 6.0, epsilon = 1e-6);
1374        assert_eq!(genome.dims(), [1, 2]);
1375    }
1376
1377    /// Issue #147 §7.2 eigenvalue-floor clamp: a degenerate (exactly zero)
1378    /// eigenvalue is floored to the relative floor `λ_max · CONDITION_FLOOR`,
1379    /// strictly above zero, so `√Λ` and `1/√Λ` both stay finite. Without the
1380    /// floor the `1/√Λ` used in `tell`'s `C^{-1/2}` would diverge to `+∞`.
1381    #[test]
1382    fn eigenvalue_floor_clamps_degenerate_eigenvalue() {
1383        // λ_max = 1, one exactly-zero eigenvalue.
1384        let eigvals: Vec<f32> = vec![1.0, 0.0];
1385        let floor: f32 = eigenvalue_floor(&eigvals);
1386        // Relative floor dominates the absolute backstop: 1·1e-14 > 1e-20.
1387        assert_eq!(floor.to_bits(), CONDITION_FLOOR.to_bits());
1388        assert!(floor > EIGENVALUE_FLOOR);
1389
1390        // The zero eigenvalue is lifted strictly above zero.
1391        let clamped: f32 = eigvals[1].max(floor);
1392        assert!(clamped > 0.0, "floored eigenvalue must be positive");
1393        assert!(clamped.sqrt().is_finite(), "√Λ must be finite");
1394        assert!((1.0 / clamped.sqrt()).is_finite(), "1/√Λ must be finite");
1395
1396        // Contrast: the un-floored zero eigenvalue would diverge under 1/√Λ.
1397        assert!(
1398            !(1.0f32 / eigvals[1].sqrt()).is_finite(),
1399            "un-floored 1/√0 must diverge — proves the floor is load-bearing"
1400        );
1401    }
1402
1403    /// Issue #147 §7.2: `update_best` on an empty population is a no-op — it
1404    /// short-circuits before touching the population tensor, leaving best-so-far
1405    /// tracking untouched (no panic, no spurious best).
1406    #[test]
1407    fn update_best_empty_population_is_noop() {
1408        let strategy = CmaEs::<Flex>::new();
1409        let params = CmaEsConfig::with_pop_size(6, 2);
1410        let device = Default::default();
1411        let mut rng = StdRng::seed_from_u64(0x0E11_0E11);
1412
1413        let mut state = strategy.init(&params, &mut rng, &device);
1414        // Any population tensor; the empty fitness slice short-circuits before it
1415        // is read.
1416        let pop = Tensor::<Flex, 2>::from_data(TensorData::new(vec![0.0f32, 0.0], [1, 2]), &device);
1417        update_best(&mut state, &pop, &[]);
1418
1419        assert!(
1420            state.best_genome().is_none(),
1421            "empty population must not set a best genome"
1422        );
1423        assert_eq!(
1424            state.best_fitness().to_bits(),
1425            f32::NEG_INFINITY.to_bits(),
1426            "empty population must not move best fitness off its sentinel"
1427        );
1428    }
1429
1430    proptest! {
1431        // Backend-heavy property: each case instantiates `Flex` and runs several
1432        // full generations, so the case count and shrink budget are capped to
1433        // keep CI cost bounded (task §239 §7.3).
1434        #![proptest_config(ProptestConfig {
1435            cases: 16,
1436            max_shrink_iters: 256,
1437            ..ProptestConfig::default()
1438        })]
1439
1440        /// Issue #239 §7.3: across a bounded `(λ, D, seed)` space, a full
1441        /// `init → ask → tell` drive over several generations preserves the
1442        /// CMA-ES structural invariants — offspring shape `[λ, D]`, bit-exact
1443        /// covariance symmetry, positive-definiteness (every eigenvalue and
1444        /// diagonal variance strictly positive), a finite search distribution,
1445        /// and the `best()` lifecycle (`None` before the first `tell`, then a
1446        /// `Some((genome, fit))` with a `[1, D]` genome).
1447        ///
1448        /// RNG boundary (ADR 0029): proptest samples *only* host config; the
1449        /// algorithm draws from a seeded `StdRng`, so proptest's PRNG never
1450        /// touches Burn and every assertion is thread-count-invariant.
1451        #[test]
1452        fn cma_es_drive_preserves_invariants(
1453            lambda in 2usize..=64,
1454            d in 1usize..=20,
1455            seed in any::<u64>(),
1456        ) {
1457            let strategy = CmaEs::<Flex>::new();
1458            let params = CmaEsConfig::with_pop_size(lambda, d);
1459            // Restrict the sampled `(λ, D)` box to the valid-config subset: in
1460            // the small-`D` / large-`λ` corner the derived `c_1 + c_mu` rounds
1461            // fractionally past 1.0, which `validate()` rejects. We only drive
1462            // valid configs here; the `Err` path is covered by dedicated tests.
1463            prop_assume!(params.validate().is_ok());
1464            let device = Default::default();
1465            let mut rng = StdRng::seed_from_u64(seed);
1466
1467            // Synthetic strictly-descending fitness of length λ (canonical
1468            // maximise: row 0 is the fittest offspring).
1469            // Precision loss is irrelevant — these are small ordinal ranks used
1470            // only for ordering, never compared for exact magnitude.
1471            #[allow(clippy::cast_precision_loss)]
1472            let fitness_vals: Vec<f32> = (0..lambda).map(|i| (lambda - i) as f32).collect();
1473
1474            let mut state = strategy.init(&params, &mut rng, &device);
1475            // best() lifecycle: `None` before the first `tell`.
1476            prop_assert!(
1477                strategy.best(&state).is_none(),
1478                "best must be None before the first tell"
1479            );
1480
1481            for _generation in 0..4 {
1482                let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
1483                // Invariant 1: `ask` yields exactly `[λ, D]` offspring.
1484                prop_assert_eq!(population.dims(), [lambda, d], "ask output shape");
1485
1486                let fitness = Tensor::<Flex, 1>::from_data(
1487                    TensorData::new(fitness_vals.clone(), [lambda]),
1488                    &device,
1489                );
1490                let (told, _metrics) =
1491                    strategy.tell(&params, population, fitness, asked, &mut rng);
1492
1493                let cov: &[f32] = told.cov();
1494                // Invariant 2: covariance is *bit-exact* symmetric (was relative
1495                // pre-#241). The rank-μ update now factors the bare
1496                // outer-product term before the per-rank weight, so each (i,j)
1497                // and (j,i) contribution is bit-identical, and a `symmetrize`
1498                // backstop runs after the loop. Symmetry is therefore guaranteed
1499                // by construction across the whole sampled space — no ULP
1500                // divergence between the transposed triangle entries.
1501                for i in 0..d {
1502                    for j in 0..d {
1503                        prop_assert_eq!(
1504                            cov[i * d + j].to_bits(),
1505                            cov[j * d + i].to_bits(),
1506                            "asymmetry at ({}, {})",
1507                            i,
1508                            j
1509                        );
1510                    }
1511                }
1512                // Invariant 3: positive-definite — every eigenvalue and every
1513                // diagonal variance is strictly positive.
1514                let eig: SymEigen = jacobi_eigen(cov, d);
1515                prop_assert!(
1516                    eig.values.iter().all(|&l| l > 0.0),
1517                    "covariance not positive-definite: eigenvalues {:?}",
1518                    eig.values
1519                );
1520                for i in 0..d {
1521                    prop_assert!(cov[i * d + i] > 0.0, "non-positive variance at {}", i);
1522                }
1523
1524                // Invariant 4: the search distribution stays finite.
1525                prop_assert!(told.mean().iter().all(|v| v.is_finite()), "mean finite");
1526                prop_assert!(told.cov().iter().all(|v| v.is_finite()), "cov finite");
1527                prop_assert!(told.sigma().is_finite(), "sigma finite");
1528
1529                // Invariant 5: `best()` is `Some` with a `[1, D]` genome after a
1530                // `tell`.
1531                let best = strategy.best(&told);
1532                prop_assert!(best.is_some(), "best must be Some after a tell");
1533                let (genome, fit): (Tensor<Flex, 2>, f32) =
1534                    best.expect("best is Some after a tell");
1535                prop_assert!(fit.is_finite(), "best fitness finite");
1536                prop_assert_eq!(genome.dims(), [1, d], "best genome shape");
1537
1538                state = told;
1539            }
1540        }
1541    }
1542}