Skip to main content

rlevo_evolution/algorithms/
cmsa_es.rs

1//! Covariance Matrix Self-Adaptation Evolution Strategy (CMSA-ES).
2//!
3//! CMSA-ES (Beyer & Sendhoff, 2008) is the path-free cousin of CMA-ES. It
4//! drops the evolution paths and Cumulative Step-size Adaptation entirely and
5//! instead:
6//!
7//! - self-adapts the step size **per individual** with the classical
8//!   log-normal rule `σᵢ = σ̄ · exp(τ · N(0, 1))`, then recombines the selected
9//!   `σᵢ` into the next `σ̄` (the same σ-self-adaptation mechanism as
10//!   [`crate::algorithms::es_classical`], so the two ES σ-adaptation families
11//!   share one mutation rule);
12//! - blends the covariance toward the rank-μ maximum-likelihood estimate of the
13//!   selected mutation steps with time constant
14//!   `τ_c = 1 + D(D+1)/(2μ)`:
15//!   `C ← (1 − 1/τ_c) C + (1/τ_c) · (1/μ) Σ s_{(i)} s_{(i)}ᵀ`.
16//!
17//! Sampling needs only a Cholesky factor of `C` (no eigendecomposition,
18//! no `C^{-1/2}`), so each generation is cheaper than CMA-ES.
19//!
20//! # On the τ constant
21//!
22//! The canonical CMSA-ES learning rate is `τ = 1/√(2D)` (Beyer & Sendhoff,
23//! 2008), used here. Note this differs from
24//! [`EsConfig`](crate::algorithms::es_classical::EsConfig)'s `1/√(2√D)`: the two
25//! strategies share the log-normal σ-self-adaptation *mechanism* (ADR 0021 §5),
26//! but CMSA-ES keeps its own algorithm-faithful constant.
27//!
28//! # Relationship to the EDA / `ProbabilityModel` family
29//!
30//! Like [`CmaEs`](crate::algorithms::cma_es), this is a self-contained
31//! [`Strategy`]; per ADR 0021 it does not instantiate
32//! [`ProbabilityModel`](crate::ProbabilityModel). The rank-μ covariance blend
33//! is closer to an EMNA-style maximum-likelihood update than CMA-ES's
34//! path-driven adaptation, but the per-individual σ self-adaptation keeps it on
35//! the ES side of the boundary (research note `eda-vs-cma-es-boundary`).
36//!
37//! # References
38//!
39//! - Beyer, H.-G. & Sendhoff, B. (2008), *Covariance Matrix Adaptation
40//!   Revisited — The CMSA Evolution Strategy*, PPSN X, LNCS 5199.
41
42use std::marker::PhantomData;
43
44use burn::tensor::{Tensor, TensorData, backend::Backend};
45use rand::Rng;
46use rand::RngExt;
47
48use rlevo_core::bounds::Bounds;
49use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
50
51use crate::ops::linalg::{cholesky, matvec, symmetrize};
52use crate::rng::{SeedPurpose, seed_stream};
53use crate::strategy::{Strategy, StrategyMetrics};
54
55/// Cholesky factor of `cov`, recovering from a non-positive-definite covariance
56/// by adding **trace-proportional** diagonal jitter and retrying with
57/// geometrically growing magnitude.
58///
59/// The jitter base scales with the mean eigenvalue (`trace/D`), so it stays
60/// meaningful regardless of the covariance magnitude — a fixed absolute jitter
61/// is too small once `C` has grown to `O(1)` entries. Only if every retry fails
62/// (a genuinely degenerate `C`) does it fall back to the identity factor, which
63/// keeps the sampling distribution valid at the cost of one generation's learned
64/// shape.
65fn cholesky_with_jitter(cov: &[f32], d: usize) -> Vec<f32> {
66    if let Some(l) = cholesky(cov, d) {
67        return l;
68    }
69    let trace: f32 = (0..d).map(|i| cov[i * d + i]).sum();
70    #[allow(clippy::cast_precision_loss)]
71    let mean_diag: f32 = (trace / d as f32).max(f32::MIN_POSITIVE);
72    let mut jitter: f32 = mean_diag * 1e-8;
73    for _ in 0..6 {
74        let mut jittered: Vec<f32> = cov.to_vec();
75        for i in 0..d {
76            jittered[i * d + i] += jitter;
77        }
78        if let Some(l) = cholesky(&jittered, d) {
79            return l;
80        }
81        jitter *= 10.0;
82    }
83    // Degenerate covariance: fall back to the identity factor.
84    let mut id: Vec<f32> = vec![0.0; d * d];
85    for i in 0..d {
86        id[i * d + i] = 1.0;
87    }
88    id
89}
90
91/// Static configuration for a CMSA-ES run.
92#[derive(Debug, Clone)]
93pub struct CmsaEsConfig {
94    /// Offspring population size `λ`.
95    pub pop_size: usize,
96    /// Genome dimensionality `D`.
97    pub genome_dim: usize,
98    /// Search-space bounds; used only to sample the initial mean `m⁰`.
99    pub bounds: Bounds,
100    /// Initial global step size `σ̄`.
101    pub initial_sigma: f32,
102    /// Number of selected parents `μ = ⌊λ/2⌋`.
103    pub mu: usize,
104    /// Log-normal σ-self-adaptation learning rate `τ = 1/√(2D)`.
105    pub tau: f32,
106    /// Covariance time constant `τ_c = 1 + D(D+1)/(2μ)`.
107    pub tau_c: f32,
108}
109
110impl CmsaEsConfig {
111    /// Default configuration for dimensionality `D`, with the Hansen-style
112    /// population `λ = 4 + ⌊3 ln D⌋` and `μ = ⌊λ/2⌋`.
113    ///
114    /// Sets `bounds = (-5.12, 5.12)` and `initial_sigma = 1.0`.
115    #[must_use]
116    pub fn default_for(genome_dim: usize) -> Self {
117        #[allow(clippy::cast_precision_loss)]
118        let d = genome_dim as f32;
119        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
120        let lambda = 4 + (3.0 * d.ln()).floor() as usize;
121        Self::with_pop_size(lambda, genome_dim)
122    }
123
124    /// Configuration with an explicit population size `λ`.
125    ///
126    /// The `pop_size ≥ 2` invariant is enforced by [`Validate::validate`] at the
127    /// harness chokepoint, not by this infallible producer.
128    #[must_use]
129    pub fn with_pop_size(pop_size: usize, genome_dim: usize) -> Self {
130        #[allow(clippy::cast_precision_loss)]
131        let d = genome_dim as f32;
132        let mu: usize = pop_size / 2;
133        #[allow(clippy::cast_precision_loss)]
134        let mu_f = mu as f32;
135        let tau: f32 = 1.0 / (2.0 * d).sqrt();
136        let tau_c: f32 = 1.0 + d * (d + 1.0) / (2.0 * mu_f);
137        Self {
138            pop_size,
139            genome_dim,
140            bounds: Bounds::new(-5.12, 5.12),
141            initial_sigma: 1.0,
142            mu,
143            tau,
144            tau_c,
145        }
146    }
147}
148
149impl Validate for CmsaEsConfig {
150    fn validate(&self) -> Result<(), ConfigError> {
151        const C: &str = "CmsaEsConfig";
152        config::at_least(C, "pop_size", self.pop_size, 2)?;
153        config::nonzero(C, "genome_dim", self.genome_dim)?;
154        config::positive(C, "initial_sigma", f64::from(self.initial_sigma))?;
155        config::at_least(C, "mu", self.mu, 1)?;
156        if self.mu > self.pop_size {
157            return Err(ConfigError {
158                config: C,
159                field: "mu",
160                kind: ConstraintKind::Custom("mu must not exceed pop_size"),
161            });
162        }
163        config::positive(C, "tau", f64::from(self.tau))?;
164        config::in_range(C, "tau_c", 1.0, f64::INFINITY, f64::from(self.tau_c))?;
165        Ok(())
166    }
167}
168
169/// Generation state for [`CmsaEs`].
170///
171/// All adaptive quantities live here (not in [`CmsaEsConfig`]) so instances stay
172/// lock-free across parallel runs. Linear-algebra state — the mean and
173/// covariance — is held host-side as `Vec<f32>`; only the offspring population
174/// crosses to the device.
175#[derive(Debug, Clone)]
176pub struct CmsaEsState<B: Backend> {
177    /// Distribution mean `m`, length `D`.
178    mean: Vec<f32>,
179    /// Covariance matrix `C`, row-major `D × D`.
180    cov: Vec<f32>,
181    /// Global step size `σ̄`.
182    sigma: f32,
183    /// Per-offspring step sizes `σᵢ`, carried `ask → tell` (length `λ`, empty
184    /// before the first `ask`). Mirrors the σ-scratchpad pattern in
185    /// [`EsState`](crate::algorithms::es_classical::EsState).
186    offspring_sigmas: Vec<f32>,
187    /// Completed-generation counter.
188    generation: usize,
189    /// Best-so-far genome, shape `(1, D)`.
190    best_genome: Option<Tensor<B, 2>>,
191    /// Best-so-far fitness (canonical maximise convention).
192    best_fitness: f32,
193}
194
195impl<B: Backend> CmsaEsState<B> {
196    /// Assembles a CMSA-ES state, checking the distribution parameters are
197    /// dimensionally consistent and normalizing `cov` to exact symmetry.
198    ///
199    /// The supplied `cov` is symmetrized in place via
200    /// [`crate::ops::linalg::symmetrize`] before construction. The in-loop
201    /// covariance blend in [`tell`](CmsaEs::tell) already preserves bit-exact
202    /// symmetry — IEEE-754 multiplication is commutative and the two triangle
203    /// entries `C[i,j]` / `C[j,i]` accumulate the identical rank-μ terms in the
204    /// identical order — so caller-supplied construction is the *only* asymmetry
205    /// entry point. Normalizing it here mirrors `pycma` practice and the
206    /// ADR 0034 sanitize-at-chokepoint convention.
207    ///
208    /// # Errors
209    ///
210    /// Returns a [`ConfigError`] if `mean` is empty, if `cov` is not `D × D`
211    /// row-major (`D = mean.len()`), or if `sigma` is not strictly positive and
212    /// finite. No length constraint is imposed on `offspring_sigmas`: it may be
213    /// empty (the pre-`ask` state) or any length — [`tell`](CmsaEs::tell) falls
214    /// back to `sigma` for any missing entry.
215    #[allow(clippy::too_many_arguments)]
216    pub fn try_new(
217        mean: Vec<f32>,
218        mut cov: Vec<f32>,
219        sigma: f32,
220        offspring_sigmas: Vec<f32>,
221        generation: usize,
222        best_genome: Option<Tensor<B, 2>>,
223        best_fitness: f32,
224    ) -> Result<Self, ConfigError> {
225        let d = mean.len();
226        config::nonzero("CmsaEsState", "mean", d)?;
227        if cov.len() != d * d {
228            return Err(ConfigError {
229                config: "CmsaEsState",
230                field: "cov",
231                kind: ConstraintKind::Custom("covariance must be a row-major D × D matrix"),
232            });
233        }
234        config::positive("CmsaEsState", "sigma", f64::from(sigma))?;
235        symmetrize(&mut cov, d);
236        Ok(Self {
237            mean,
238            cov,
239            sigma,
240            offspring_sigmas,
241            generation,
242            best_genome,
243            best_fitness,
244        })
245    }
246
247    /// Distribution mean `m`, length `D`.
248    #[must_use]
249    pub fn mean(&self) -> &[f32] {
250        &self.mean
251    }
252
253    /// Covariance matrix `C`, row-major `D × D`.
254    #[must_use]
255    pub fn cov(&self) -> &[f32] {
256        &self.cov
257    }
258
259    /// Global step size `σ̄`.
260    #[must_use]
261    pub fn sigma(&self) -> f32 {
262        self.sigma
263    }
264
265    /// Per-offspring step sizes `σᵢ`, carried `ask → tell` (empty before the
266    /// first `ask`).
267    #[must_use]
268    pub fn offspring_sigmas(&self) -> &[f32] {
269        &self.offspring_sigmas
270    }
271
272    /// Completed-generation counter.
273    #[must_use]
274    pub fn generation(&self) -> usize {
275        self.generation
276    }
277
278    /// Best-so-far genome (shape `(1, D)`), or `None` before the first `tell`.
279    #[must_use]
280    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
281        self.best_genome.as_ref()
282    }
283
284    /// Best-so-far (canonical, maximise) fitness.
285    #[must_use]
286    pub fn best_fitness(&self) -> f32 {
287        self.best_fitness
288    }
289}
290
291/// Covariance Matrix Self-Adaptation Evolution Strategy.
292///
293/// # Example
294///
295/// ```no_run
296/// use burn::backend::Flex;
297/// use rlevo_evolution::algorithms::cmsa_es::{CmsaEsConfig, CmsaEs};
298///
299/// let strategy = CmsaEs::<Flex>::new();
300/// let params = CmsaEsConfig::default_for(10);
301/// let _ = (strategy, params);
302/// ```
303#[derive(Debug, Clone, Copy, Default)]
304pub struct CmsaEs<B: Backend> {
305    _backend: PhantomData<fn() -> B>,
306}
307
308impl<B: Backend> CmsaEs<B> {
309    /// Builds a new (stateless) strategy object.
310    #[must_use]
311    pub fn new() -> Self {
312        Self {
313            _backend: PhantomData,
314        }
315    }
316}
317
318impl<B: Backend> Strategy<B> for CmsaEs<B>
319where
320    B::Device: Clone,
321{
322    type Params = CmsaEsConfig;
323    type State = CmsaEsState<B>;
324    type Genome = Tensor<B, 2>;
325
326    /// Initializes `m⁰` uniformly in `params.bounds` (host-RNG convention),
327    /// `C = I`, and `σ̄ = initial_sigma`.
328    fn init(
329        &self,
330        params: &CmsaEsConfig,
331        rng: &mut dyn Rng,
332        _device: &<B as burn::tensor::backend::BackendTypes>::Device,
333    ) -> CmsaEsState<B> {
334        debug_assert!(
335            params.validate().is_ok(),
336            "invalid CmsaEsConfig reached init: {params:?}"
337        );
338        let d = params.genome_dim;
339        let (lo, hi): (f32, f32) = params.bounds.into();
340        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
341        let mean: Vec<f32> = (0..d)
342            .map(|_| lo + (hi - lo) * stream.random::<f32>())
343            .collect();
344        let mut cov: Vec<f32> = vec![0.0; d * d];
345        for i in 0..d {
346            cov[i * d + i] = 1.0;
347        }
348        CmsaEsState {
349            mean,
350            cov,
351            sigma: params.initial_sigma,
352            offspring_sigmas: Vec::new(),
353            generation: 0,
354            best_genome: None,
355            best_fitness: f32::NEG_INFINITY,
356        }
357    }
358
359    /// Samples `λ` offspring with per-individual log-normal step sizes.
360    ///
361    /// For each offspring: `σᵢ = σ̄ · exp(τ · N(0,1))`, `sᵢ = A zᵢ`
362    /// (`A` the Cholesky factor of `C`, `zᵢ ~ N(0, I)`), `xᵢ = m + σᵢ · sᵢ`.
363    /// All draws come from one deterministic [`SeedPurpose::CmaSampling`]
364    /// stream; the `σᵢ` are stashed in state for [`tell`](Self::tell).
365    fn ask(
366        &self,
367        params: &CmsaEsConfig,
368        state: &CmsaEsState<B>,
369        rng: &mut dyn Rng,
370        device: &<B as burn::tensor::backend::BackendTypes>::Device,
371    ) -> (Tensor<B, 2>, CmsaEsState<B>) {
372        let d = params.genome_dim;
373        let lambda = params.pop_size;
374
375        // Cholesky factor A of C, with trace-relative jitter recovery on a PD
376        // failure (see `cholesky_with_jitter`).
377        let factor: Vec<f32> = cholesky_with_jitter(&state.cov, d);
378
379        let mut stream = seed_stream(
380            rng.next_u64(),
381            state.generation as u64,
382            SeedPurpose::CmaSampling,
383        );
384        let mut rows: Vec<f32> = Vec::with_capacity(lambda * d);
385        let mut sigmas: Vec<f32> = Vec::with_capacity(lambda);
386        for _ in 0..lambda {
387            // Floor σᵢ at the smallest positive f32. `exp` of a large negative
388            // draw underflows to exactly `0.0` in f32; `tell` would then compute
389            // sᵢ = (xᵢ − m)/σᵢ = 0/0 = NaN, which permanently poisons the
390            // covariance blend. This floor matches the CSA σ floor in
391            // cma_es.rs. A floored σᵢ yields a *benign zero step* — the
392            // offspring collapses to ≈m and contributes ~0 to the rank-μ blend —
393            // not a corrected tiny step.
394            let sigma_i: f32 = (state.sigma
395                * (params.tau * crate::sampling::standard_normal(&mut stream)).exp())
396            .max(f32::MIN_POSITIVE);
397            let z: Vec<f32> = (0..d)
398                .map(|_| crate::sampling::standard_normal(&mut stream))
399                .collect();
400            let s: Vec<f32> = matvec(&factor, &z, d);
401            for (mean_i, s_i) in state.mean.iter().zip(s.iter()) {
402                rows.push(mean_i + sigma_i * s_i);
403            }
404            sigmas.push(sigma_i);
405        }
406
407        let population = Tensor::<B, 2>::from_data(TensorData::new(rows, [lambda, d]), device);
408        let mut next: CmsaEsState<B> = state.clone();
409        next.offspring_sigmas = sigmas;
410        (population, next)
411    }
412
413    /// Recombines the `μ` best offspring into the next mean, step size, and
414    /// rank-μ covariance blend.
415    fn tell(
416        &self,
417        params: &CmsaEsConfig,
418        population: Tensor<B, 2>,
419        fitness: Tensor<B, 1>,
420        mut state: CmsaEsState<B>,
421        _rng: &mut dyn Rng,
422    ) -> (CmsaEsState<B>, StrategyMetrics) {
423        let d = params.genome_dim;
424        let lambda = params.pop_size;
425        let mu = params.mu;
426
427        let fitness_host: Vec<f32> = fitness
428            .into_data()
429            .into_vec::<f32>()
430            .expect("fitness tensor must be readable as f32");
431        let pop_host: Vec<f32> = population
432            .clone()
433            .into_data()
434            .into_vec::<f32>()
435            .expect("population tensor must be readable as f32");
436
437        // Rank descending (canonical maximise); take the μ best (highest).
438        let mut ranked: Vec<usize> = (0..lambda).collect();
439        // Sanitize NaN → −inf (worst) so it can never rank as best, then order
440        // by `total_cmp` (deterministic; sanitized NaN sorts last).
441        let sane: Vec<f32> = fitness_host
442            .iter()
443            .map(|&f| crate::fitness::sanitize_fitness(f))
444            .collect();
445        ranked.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
446
447        let m_old: Vec<f32> = state.mean.clone();
448        #[allow(clippy::cast_precision_loss)]
449        let inv_mu: f32 = 1.0 / mu as f32;
450
451        // New mean (equal-weight recombination), new σ̄ (mean of selected σᵢ),
452        // and the mutation steps s_{(i)} = (x_{(i)} − m) / σᵢ for rank-μ.
453        let mut mean_new: Vec<f32> = vec![0.0; d];
454        let mut sigma_sum: f32 = 0.0;
455        let mut s_sel: Vec<Vec<f32>> = Vec::with_capacity(mu);
456        for &idx in ranked.iter().take(mu) {
457            let sigma_i = state
458                .offspring_sigmas
459                .get(idx)
460                .copied()
461                .unwrap_or(state.sigma);
462            sigma_sum += sigma_i;
463            let mut si: Vec<f32> = vec![0.0; d];
464            for i in 0..d {
465                let xi = pop_host[idx * d + i];
466                mean_new[i] += inv_mu * xi;
467                si[i] = (xi - m_old[i]) / sigma_i;
468            }
469            s_sel.push(si);
470        }
471        let sigma_new: f32 = sigma_sum * inv_mu;
472
473        // Rank-μ ML covariance blend:
474        // C ← (1 − 1/τ_c) C + (1/τ_c) (1/μ) Σ s_{(i)} s_{(i)}ᵀ.
475        let blend: f32 = 1.0 / params.tau_c;
476        let c_old: Vec<f32> = state.cov.clone();
477        let mut cov_new: Vec<f32> = vec![0.0; d * d];
478        for i in 0..d {
479            for j in 0..d {
480                let mut rankmu: f32 = 0.0;
481                for si in &s_sel {
482                    rankmu += si[i] * si[j];
483                }
484                rankmu *= inv_mu;
485                cov_new[i * d + j] = (1.0 - blend) * c_old[i * d + j] + blend * rankmu;
486            }
487        }
488        // Defensive float-drift hygiene (pycma-style): the Beyer & Sendhoff
489        // (2008, PPSN X) rank-μ blend is symmetric by construction, so this
490        // re-symmetrization is a no-op today; it guards the solver's symmetry
491        // assumption against a future edit that reorders the accumulation.
492        symmetrize(&mut cov_new, d);
493
494        update_best(&mut state, &population, &fitness_host);
495
496        state.generation += 1;
497        let metrics =
498            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
499        state.best_fitness = metrics.best_fitness_ever();
500
501        state.mean = mean_new;
502        state.cov = cov_new;
503        state.sigma = sigma_new;
504        state.offspring_sigmas = Vec::new();
505
506        (state, metrics)
507    }
508
509    /// Returns the best-so-far genome and its fitness, or `None` before the
510    /// first [`tell`](Self::tell) call.
511    fn best(&self, state: &CmsaEsState<B>) -> Option<(Tensor<B, 2>, f32)> {
512        state
513            .best_genome
514            .as_ref()
515            .map(|g| (g.clone(), state.best_fitness))
516    }
517}
518
519/// Updates `state.best_genome` / `state.best_fitness` if this generation
520/// improved on the best-so-far.
521fn update_best<B: Backend>(state: &mut CmsaEsState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
522    if fitness.is_empty() {
523        return;
524    }
525    let mut best_idx: usize = 0;
526    let mut best: f32 = f32::NEG_INFINITY;
527    for (i, &f) in fitness.iter().enumerate() {
528        if f > best {
529            best = f;
530            best_idx = i;
531        }
532    }
533    if best > state.best_fitness {
534        let device = pop.device();
535        #[allow(clippy::cast_possible_wrap)]
536        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
537            TensorData::new(vec![best_idx as i64], [1]),
538            &device,
539        );
540        state.best_genome = Some(pop.clone().select(0, idx));
541        state.best_fitness = best;
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use burn::backend::Flex;
549    use proptest::prelude::*;
550    use rand::SeedableRng;
551    use rand::rngs::StdRng;
552
553    /// Reconstruct `L · Lᵀ` for a row-major `n × n` lower-triangular factor.
554    fn recon_llt(l: &[f32], n: usize) -> Vec<f32> {
555        let mut out: Vec<f32> = vec![0.0; n * n];
556        for i in 0..n {
557            for j in 0..n {
558                let mut acc: f32 = 0.0;
559                for k in 0..n {
560                    acc += l[i * n + k] * l[j * n + k];
561                }
562                out[i * n + j] = acc;
563            }
564        }
565        out
566    }
567
568    #[test]
569    fn cholesky_with_jitter_recovers_from_non_pd_covariance() {
570        // Issue #147 §7.2 jitter-recovery coverage. Fixture: a diagonal (hence
571        // symmetric) NON-positive-definite covariance with eigenvalues
572        // {−1e-5, 1} — for a diagonal matrix the eigenvalues *are* the diagonal
573        // entries. The −1e-5 pivot makes the un-jittered `cholesky` return
574        // `None`, forcing the trace-proportional jitter path.
575        //
576        // mean_diag = (−1e-5 + 1)/2 ≈ 0.5, so jitter starts at ≈5e-9 and grows
577        // ×10 per retry. A `+jitter·I` shift moves every eigenvalue by exactly
578        // +jitter, so the smallest eigenvalue (−1e-5 + jitter) first turns
579        // positive at jitter = 5e-5 — retry index 4 of 6, two retries to spare.
580        // Empirically the JITTER-RECOVERY branch fires here (factor
581        // ≈ [6.32e-3, 0, 0, 1.000025]); the identity fallback does NOT.
582        let cov: Vec<f32> = vec![-1e-5, 0.0, 0.0, 1.0];
583        let factor: Vec<f32> = cholesky_with_jitter(&cov, 2);
584
585        assert!(
586            factor.iter().all(|x| x.is_finite()),
587            "factor has non-finite entries: {factor:?}"
588        );
589        // Lower-triangular: the strict-upper entry is exactly zero.
590        approx::assert_relative_eq!(factor[1], 0.0, epsilon = 1e-9);
591        // Genuine Cholesky factor: strictly positive pivots.
592        assert!(
593            factor[0] > 0.0 && factor[3] > 0.0,
594            "non-positive pivots: {factor:?}"
595        );
596
597        // L·Lᵀ ≈ the jittered covariance. The (0,0) entry is the recovered
598        // ≈4e-5, NOT 1.0 — the proof the identity fallback did NOT fire (that
599        // branch would return L = I, giving L·Lᵀ (0,0) = 1.0).
600        let recon: Vec<f32> = recon_llt(&factor, 2);
601        assert!(
602            recon[0] < 0.5,
603            "identity fallback fired instead of jitter recovery: recon = {recon:?}"
604        );
605        // The (1,1) entry stays ≈1 (jitter is only O(1e-5)).
606        approx::assert_relative_eq!(recon[3], 1.0, epsilon = 1e-3);
607    }
608
609    #[test]
610    fn cholesky_with_jitter_falls_back_to_identity_when_degenerate() {
611        // Issue #147 §7.2 fallback-branch coverage. Fixture: a symmetric,
612        // strongly indefinite covariance [[1, 2], [2, 1]] with eigenvalues
613        // {3, −1} (the same indefinite matrix `linalg::cholesky_rejects_non_
614        // positive_definite` uses). The jitter shifts every eigenvalue by
615        // +jitter, but jitter tops out at mean_diag·1e-8·10⁵ = 1·1e-3 after the
616        // 6 retries — far too small to lift the −1 eigenvalue positive, so
617        // every retry's (1,1) pivot stays negative. Empirically all 6 retries
618        // fail and the function returns the IDENTITY factor.
619        let cov: Vec<f32> = vec![1.0, 2.0, 2.0, 1.0];
620        let factor: Vec<f32> = cholesky_with_jitter(&cov, 2);
621
622        assert!(
623            factor.iter().all(|x| x.is_finite()),
624            "factor has non-finite entries: {factor:?}"
625        );
626        // Exactly the identity factor: diagonal ones, zero off-diagonal.
627        approx::assert_relative_eq!(factor[0], 1.0, epsilon = 1e-9);
628        approx::assert_relative_eq!(factor[1], 0.0, epsilon = 1e-9);
629        approx::assert_relative_eq!(factor[2], 0.0, epsilon = 1e-9);
630        approx::assert_relative_eq!(factor[3], 1.0, epsilon = 1e-9);
631    }
632
633    #[test]
634    fn ask_tell_round_trip_survives_non_pd_covariance() {
635        // Issue #153 ask/tell round-trip guard on the ill-conditioned-covariance
636        // hazard. `try_new` symmetrizes `cov`, so a caller cannot inject
637        // asymmetry — but it CAN inject a SYMMETRIC non-PD covariance, which is
638        // the reachable path into `cholesky_with_jitter`. We reuse the
639        // recovery-branch fixture (diagonal, eigenvalues {−1e-5, 1}); `ask` must
640        // route it through the jitter recovery, sample valid offspring, and the
641        // `ask → tell` round-trip must leave cov/mean/σ̄ finite (no NaN/inf
642        // leaking from the ill-conditioned factor). Mirrors the structure of
643        // `sigma_i_underflow_does_not_poison_covariance`.
644        let strategy = CmsaEs::<Flex>::new();
645        let params = CmsaEsConfig::with_pop_size(8, 2);
646        let device = Default::default();
647        let mut rng = StdRng::seed_from_u64(1);
648
649        // Symmetric but non-PD: eigenvalue −1e-5 survives `symmetrize` (the
650        // matrix is already symmetric) and reaches `ask`'s Cholesky.
651        let state: CmsaEsState<Flex> = CmsaEsState::try_new(
652            vec![0.0, 0.0],
653            vec![-1e-5, 0.0, 0.0, 1.0],
654            1.0,
655            Vec::new(),
656            0,
657            None,
658            f32::NEG_INFINITY,
659        )
660        .expect("valid state");
661
662        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
663        // Any finite fitness — ranking is irrelevant to the non-finite hazard.
664        let fitness = Tensor::<Flex, 1>::from_data(
665            TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], [8]),
666            &device,
667        );
668        let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
669
670        assert!(
671            told.cov().iter().all(|c| c.is_finite()),
672            "covariance has non-finite entries: {:?}",
673            told.cov()
674        );
675        assert!(
676            told.mean().iter().all(|m| m.is_finite()),
677            "mean has non-finite entries: {:?}",
678            told.mean()
679        );
680        assert!(
681            told.sigma().is_finite() && told.sigma() > 0.0,
682            "sigma is not finite and positive: {}",
683            told.sigma()
684        );
685    }
686
687    #[test]
688    fn sigma_i_underflow_does_not_poison_covariance() {
689        // Regression for the σᵢ underflow. With a minuscule σ̄, a negative
690        // log-normal draw makes the raw σᵢ = σ̄·exp(τ·N) underflow to exactly
691        // 0.0. Without the `.max(f32::MIN_POSITIVE)` floor in `ask`, `tell` then
692        // forms sᵢ = (xᵢ − m)/σᵢ = 0/0 = NaN and poisons the rank-μ covariance
693        // blend — reverting the floor turns this test red (NaN in `cov()`,
694        // confirmed manually). The floor clamps those raw zeros up to
695        // `f32::MIN_POSITIVE`, a benign zero step (the offspring collapses to
696        // ≈m and contributes ~0 to the blend), so cov/mean/σ̄ all stay finite.
697        //
698        // We seed σ̄ at the smallest positive **subnormal** f32 rather than
699        // `f32::MIN_POSITIVE` (the smallest *normal*): from the smallest normal,
700        // an exact-0.0 underflow needs N < −33 (a ~33σ event that never fires),
701        // whereas from the smallest subnormal any N < ≈−1.4 flushes to exactly
702        // 0.0 — the realistic hazard. Because σ̄ is subnormal, *every* raw σᵢ
703        // sits below `f32::MIN_POSITIVE`, so with the floor active every entry
704        // reads back as exactly `f32::MIN_POSITIVE`; the precondition asserts the
705        // floor engaged on at least one offspring (it is the observable proxy for
706        // "an underflow would have occurred").
707        let strategy = CmsaEs::<Flex>::new();
708        let params = CmsaEsConfig::with_pop_size(8, 2);
709        let device = Default::default();
710        // Seed 1's `SeedPurpose::CmaSampling` stream draws at least one N < ≈−1.4
711        // across the 8 offspring, the draw whose raw σᵢ underflows to 0.0.
712        let mut rng = StdRng::seed_from_u64(1);
713
714        let state: CmsaEsState<Flex> = CmsaEsState::try_new(
715            vec![0.0, 0.0],
716            vec![1.0, 0.0, 0.0, 1.0],
717            f32::from_bits(1),
718            Vec::new(),
719            0,
720            None,
721            f32::NEG_INFINITY,
722        )
723        .expect("valid state");
724
725        let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
726        assert!(
727            asked.offspring_sigmas().contains(&f32::MIN_POSITIVE),
728            "test precondition: the σᵢ floor must engage on at least one offspring"
729        );
730        // Any finite fitness — the ranking is irrelevant to the NaN hazard.
731        let fitness = Tensor::<Flex, 1>::from_data(
732            TensorData::new(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], [8]),
733            &device,
734        );
735        let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
736
737        assert!(
738            told.cov().iter().all(|c| c.is_finite()),
739            "covariance has non-finite entries: {:?}",
740            told.cov()
741        );
742        assert!(
743            told.mean().iter().all(|m| m.is_finite()),
744            "mean has non-finite entries: {:?}",
745            told.mean()
746        );
747        assert!(
748            told.sigma().is_finite() && told.sigma() > 0.0,
749            "sigma is not finite and positive: {}",
750            told.sigma()
751        );
752    }
753
754    #[test]
755    fn try_new_rejects_empty_mean() {
756        let err = CmsaEsState::<Flex>::try_new(
757            Vec::new(),
758            Vec::new(),
759            0.5,
760            Vec::new(),
761            0,
762            None,
763            f32::MIN,
764        )
765        .unwrap_err();
766        assert_eq!(err.field, "mean");
767    }
768
769    #[test]
770    fn try_new_rejects_wrong_cov_length() {
771        // D = 2 wants a 4-entry cov; supply 3.
772        let err = CmsaEsState::<Flex>::try_new(
773            vec![0.0, 0.0],
774            vec![1.0, 0.0, 0.0],
775            0.5,
776            Vec::new(),
777            0,
778            None,
779            f32::MIN,
780        )
781        .unwrap_err();
782        assert_eq!(err.field, "cov");
783    }
784
785    #[test]
786    fn try_new_rejects_non_positive_sigma() {
787        let err = CmsaEsState::<Flex>::try_new(
788            vec![0.0, 0.0],
789            vec![1.0, 0.0, 0.0, 1.0],
790            0.0,
791            Vec::new(),
792            0,
793            None,
794            f32::MIN,
795        )
796        .unwrap_err();
797        assert_eq!(err.field, "sigma");
798    }
799
800    #[test]
801    fn try_new_symmetrizes_covariance() {
802        // Asymmetric off-diagonals 0.4 / 0.2 average to 0.3 on both sides.
803        let state = CmsaEsState::<Flex>::try_new(
804            vec![0.0, 0.0],
805            vec![1.0, 0.4, 0.2, 1.0],
806            0.5,
807            Vec::new(),
808            0,
809            None,
810            f32::MIN,
811        )
812        .expect("valid state");
813        approx::assert_relative_eq!(state.cov()[1], 0.3, epsilon = 1e-6);
814        approx::assert_relative_eq!(state.cov()[2], 0.3, epsilon = 1e-6);
815    }
816
817    #[test]
818    fn accessors_round_trip_constructor_values() {
819        let genome = Tensor::<Flex, 2>::from_data(
820            TensorData::new(vec![1.0f32, 2.0], [1, 2]),
821            &Default::default(),
822        );
823        let state = CmsaEsState::<Flex>::try_new(
824            vec![1.0, -2.0],
825            vec![2.0, 0.0, 0.0, 3.0],
826            0.75,
827            vec![0.1, 0.2, 0.3],
828            7,
829            Some(genome),
830            42.0,
831        )
832        .expect("valid state");
833        assert_eq!(state.mean(), &[1.0, -2.0]);
834        assert_eq!(state.cov(), &[2.0, 0.0, 0.0, 3.0]);
835        approx::assert_relative_eq!(state.sigma(), 0.75, epsilon = 1e-6);
836        assert_eq!(state.offspring_sigmas(), &[0.1, 0.2, 0.3]);
837        assert_eq!(state.generation(), 7);
838        assert!(state.best_genome().is_some());
839        approx::assert_relative_eq!(state.best_fitness(), 42.0, epsilon = 1e-6);
840    }
841
842    #[test]
843    fn default_config_validates() {
844        assert!(CmsaEsConfig::default_for(10).validate().is_ok());
845    }
846
847    #[test]
848    fn rejects_pop_size_below_two() {
849        let mut cfg = CmsaEsConfig::default_for(10);
850        cfg.pop_size = 1;
851        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
852    }
853
854    #[test]
855    fn default_for_d10_constants() {
856        let cfg = CmsaEsConfig::default_for(10);
857        assert_eq!(cfg.pop_size, 10);
858        assert_eq!(cfg.mu, 5);
859        // τ = 1/√20 ≈ 0.2236.
860        approx::assert_relative_eq!(cfg.tau, 1.0 / 20.0_f32.sqrt(), epsilon = 1e-6);
861        // τ_c = 1 + 10·11/(2·5) = 1 + 11 = 12.
862        approx::assert_relative_eq!(cfg.tau_c, 12.0, epsilon = 1e-5);
863    }
864
865    #[test]
866    fn tau_differs_from_es_classical() {
867        // CMSA-ES uses the canonical 1/√(2D); es_classical uses 1/√(2√D).
868        let cfg = CmsaEsConfig::default_for(10);
869        #[allow(clippy::cast_precision_loss)]
870        let d = 10.0_f32;
871        let es_classical_tau = 1.0 / (2.0 * d.sqrt()).sqrt();
872        assert!(
873            (cfg.tau - es_classical_tau).abs() > 0.1,
874            "canonical CMSA τ must differ from es_classical τ"
875        );
876    }
877
878    /// Issue #147 §7.2 determinism: two runs from the same seed produce
879    /// bit-identical trajectories. CMSA-ES host-samples off a `StdRng` threaded
880    /// through `init`/`ask` (which key their `seed_stream`s on `rng.next_u64()`
881    /// and the generation counter), so an identical seed and identical call
882    /// sequence must reproduce the mean, covariance, and σ̄ exactly.
883    #[test]
884    fn same_seed_yields_identical_trajectories() {
885        fn run() -> (Vec<f32>, Vec<f32>, f32) {
886            let strategy = CmsaEs::<Flex>::new();
887            let params = CmsaEsConfig::with_pop_size(8, 3);
888            let device = Default::default();
889            let mut rng = StdRng::seed_from_u64(0xD37E_2711);
890            let mut state = strategy.init(&params, &mut rng, &device);
891            for _ in 0..4 {
892                let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
893                let fitness = Tensor::<Flex, 1>::from_data(
894                    TensorData::new(vec![8.0f32, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0], [8]),
895                    &device,
896                );
897                let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
898                state = told;
899            }
900            (state.mean().to_vec(), state.cov().to_vec(), state.sigma())
901        }
902
903        let (mean_a, cov_a, sigma_a): (Vec<f32>, Vec<f32>, f32) = run();
904        let (mean_b, cov_b, sigma_b): (Vec<f32>, Vec<f32>, f32) = run();
905        assert_eq!(
906            mean_a, mean_b,
907            "mean trajectory diverged under a fixed seed"
908        );
909        assert_eq!(
910            cov_a, cov_b,
911            "covariance trajectory diverged under a fixed seed"
912        );
913        assert_eq!(
914            sigma_a.to_bits(),
915            sigma_b.to_bits(),
916            "σ̄ trajectory diverged under a fixed seed"
917        );
918    }
919
920    /// Issue #147 §7.2/§7.3 regression: the rank-μ covariance blend must keep `C`
921    /// bit-exactly symmetric across several generations. Guards the explicit
922    /// `symmetrize` at the blend chokepoint (defensive float-drift hygiene per
923    /// Beyer & Sendhoff 2008) against a future edit that reorders the outer-
924    /// product accumulation and breaks the commutativity assumption.
925    #[test]
926    fn covariance_stays_symmetric_across_generations() {
927        let strategy = CmsaEs::<Flex>::new();
928        let params = CmsaEsConfig::with_pop_size(8, 3);
929        let d: usize = params.genome_dim;
930        let device = Default::default();
931        let mut rng = StdRng::seed_from_u64(0x5A11_9E77);
932
933        let mut state = strategy.init(&params, &mut rng, &device);
934        for generation in 0..5 {
935            let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
936            let fitness = Tensor::<Flex, 1>::from_data(
937                TensorData::new(vec![8.0f32, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0], [8]),
938                &device,
939            );
940            let (told, _metrics) = strategy.tell(&params, population, fitness, asked, &mut rng);
941
942            let cov: &[f32] = told.cov();
943            for i in 0..d {
944                for j in 0..d {
945                    assert_eq!(
946                        cov[i * d + j].to_bits(),
947                        cov[j * d + i].to_bits(),
948                        "asymmetry at ({i}, {j}) in generation {generation}"
949                    );
950                }
951            }
952            state = told;
953        }
954    }
955
956    proptest! {
957        // Issue #239 §7.3: stochastic-invariant coverage for the CMSA-ES
958        // ask/tell loop. proptest generates ONLY the scalar problem shape
959        // `(lambda, d, seed)` (ADR 0029 RNG boundary); the run then seeds a
960        // `StdRng` exactly as the hand-written tests do and threads it through
961        // `init`/`ask`/`tell`, which key their own `seed_stream`s off
962        // `rng.next_u64()` + the generation counter. No `B::seed` /
963        // `Tensor::random`. `lambda >= 4` keeps `mu = lambda/2 >= 2` so the
964        // rank-µ recombination has at least two parents; `d >= 2` exercises the
965        // off-diagonal symmetry invariant. All four assertions are
966        // thread-count-invariant (sign / finiteness / bit-exact symmetry).
967        #![proptest_config(ProptestConfig {
968            cases: 16,
969            max_shrink_iters: 256,
970            ..ProptestConfig::default()
971        })]
972        #[test]
973        fn ask_tell_preserves_stochastic_invariants(
974            lambda in 4usize..=32,
975            d in 2usize..=30,
976            seed in any::<u64>(),
977        ) {
978            let strategy = CmsaEs::<Flex>::new();
979            let params = CmsaEsConfig::with_pop_size(lambda, d);
980            let device = Default::default();
981            let mut rng = StdRng::seed_from_u64(seed);
982
983            // Strictly descending, finite fitness of length `lambda`
984            // (1.0, 0.0, −1.0, …) — no cast, ranking is unambiguous.
985            let mut fitness_vals: Vec<f32> = Vec::with_capacity(lambda);
986            let mut v: f32 = 1.0;
987            for _ in 0..lambda {
988                fitness_vals.push(v);
989                v -= 1.0;
990            }
991
992            let mut state = strategy.init(&params, &mut rng, &device);
993            for generation in 0..5 {
994                let (population, asked) = strategy.ask(&params, &state, &mut rng, &device);
995
996                // (4) Every floored σᵢ is strictly positive and finite: the
997                // `.max(f32::MIN_POSITIVE)` floor in `ask` must have clamped
998                // out every raw-zero / NaN draw before `tell` forms sᵢ.
999                for (k, &s) in asked.offspring_sigmas().iter().enumerate() {
1000                    prop_assert!(
1001                        s.is_finite() && s > 0.0,
1002                        "offspring σ[{k}] not finite-positive in generation \
1003                         {generation}: {s} (lambda={lambda}, d={d}, seed={seed})"
1004                    );
1005                }
1006
1007                let fitness = Tensor::<Flex, 1>::from_data(
1008                    TensorData::new(fitness_vals.clone(), [lambda]),
1009                    &device,
1010                );
1011                let (told, _metrics) =
1012                    strategy.tell(&params, population, fitness, asked, &mut rng);
1013
1014                // (1) σ̄ stays strictly positive and finite.
1015                prop_assert!(
1016                    told.sigma().is_finite() && told.sigma() > 0.0,
1017                    "σ̄ not finite-positive in generation {generation}: {} \
1018                     (lambda={lambda}, d={d}, seed={seed})",
1019                    told.sigma()
1020                );
1021
1022                // (2) covariance stays bit-exactly symmetric.
1023                let cov: &[f32] = told.cov();
1024                for i in 0..d {
1025                    for j in 0..d {
1026                        prop_assert_eq!(
1027                            cov[i * d + j].to_bits(),
1028                            cov[j * d + i].to_bits(),
1029                            "cov asymmetry at ({}, {}) in generation {} \
1030                             (lambda={}, d={}, seed={})",
1031                            i, j, generation, lambda, d, seed
1032                        );
1033                    }
1034                }
1035
1036                // (3) covariance and mean stay finite (no NaN / inf).
1037                for (k, &c) in cov.iter().enumerate() {
1038                    prop_assert!(
1039                        c.is_finite(),
1040                        "cov[{k}] non-finite in generation {generation}: {c} \
1041                         (lambda={lambda}, d={d}, seed={seed})"
1042                    );
1043                }
1044                for (k, &m) in told.mean().iter().enumerate() {
1045                    prop_assert!(
1046                        m.is_finite(),
1047                        "mean[{k}] non-finite in generation {generation}: {m} \
1048                         (lambda={lambda}, d={d}, seed={seed})"
1049                    );
1050                }
1051
1052                state = told;
1053            }
1054        }
1055    }
1056}