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(¶ms, &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(¶ms, 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(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
891 for _ in 0..4 {
892 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
934 for generation in 0..5 {
935 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
993 for generation in 0..5 {
994 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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}