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(¶ms, &mut rng, &device);
1008 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
1051 let (population, asked) = strategy.ask(¶ms, &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(¶ms, population.clone(), f_cached, asked, &mut rng_a);
1078 let (told_recomp, _) = strategy.tell(¶ms, 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(¶ms, &mut rng, &device);
1120 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
1157 let (pop0, asked0) = strategy.ask(¶ms, &s0, &mut rng, &device);
1158 assert!(asked0.eig.is_some(), "first ask must populate the memo");
1159 let (told0, _m0) = strategy.tell(¶ms, 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(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
1198 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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(¶ms, &mut rng, &device);
1255 for generation in 0..400 {
1256 let (population, asked) = strategy.ask(¶ms, &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(¶ms, 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(¶ms, &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(¶ms, &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(¶ms, 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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, 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}