Skip to main content

fugue_evo/algorithms/
cmaes.rs

1//! CMA-ES (Covariance Matrix Adaptation Evolution Strategy)
2//!
3//! Implements the CMA-ES algorithm with full covariance matrix adaptation,
4//! evolution path management, and step-size control.
5//!
6//! Reference: Hansen, N., & Ostermeier, A. (2001). Completely Derandomized
7//! Self-Adaptation in Evolution Strategies. Evolutionary Computation, 9(2).
8
9use std::marker::PhantomData;
10
11use nalgebra::{DMatrix, SymmetricEigen};
12use rand::Rng;
13use rand_distr::{Distribution, StandardNormal};
14
15use crate::error::{EvoResult, EvolutionError};
16use crate::genome::bounds::MultiBounds;
17use crate::genome::real_vector::RealVector;
18use crate::genome::traits::RealValuedGenome;
19use crate::population::individual::Individual;
20
21/// Trait for fitness functions used with CMA-ES
22///
23/// CMA-ES is a minimization algorithm, so lower values are better.
24#[cfg(feature = "parallel")]
25pub trait CmaEsFitness: Send + Sync {
26    /// Evaluate the fitness of a solution (lower is better)
27    fn evaluate(&self, x: &RealVector) -> f64;
28}
29
30/// Trait for fitness functions used with CMA-ES
31///
32/// CMA-ES is a minimization algorithm, so lower values are better.
33#[cfg(not(feature = "parallel"))]
34pub trait CmaEsFitness {
35    /// Evaluate the fitness of a solution (lower is better)
36    fn evaluate(&self, x: &RealVector) -> f64;
37}
38
39/// CMA-ES state containing all adaptation parameters
40#[derive(Clone, Debug)]
41pub struct CmaEsState {
42    /// Current mean of the search distribution
43    pub mean: Vec<f64>,
44
45    /// Global step size (σ)
46    pub sigma: f64,
47
48    /// Covariance matrix C (stored as upper triangular for efficiency)
49    pub covariance: Vec<Vec<f64>>,
50
51    /// Evolution path for σ adaptation (p_σ)
52    pub path_sigma: Vec<f64>,
53
54    /// Evolution path for C adaptation (p_c)
55    pub path_c: Vec<f64>,
56
57    /// Eigenvalues of C (D²)
58    pub eigenvalues: Vec<f64>,
59
60    /// Eigenvectors of C (B)
61    pub eigenvectors: Vec<Vec<f64>>,
62
63    /// Generation counter for eigendecomposition
64    pub eigen_eval: usize,
65
66    /// Problem dimension
67    pub dimension: usize,
68
69    /// Population size (λ)
70    pub lambda: usize,
71
72    /// Parent number (μ)
73    pub mu: usize,
74
75    /// Recombination weights
76    pub weights: Vec<f64>,
77
78    /// Variance effective selection mass (μ_eff)
79    pub mu_eff: f64,
80
81    /// Learning rate for rank-1 update
82    pub c_1: f64,
83
84    /// Learning rate for rank-μ update
85    pub c_mu: f64,
86
87    /// Learning rate for cumulation for σ control
88    pub c_sigma: f64,
89
90    /// Damping for σ
91    pub d_sigma: f64,
92
93    /// Learning rate for cumulation for C
94    pub c_c: f64,
95
96    /// Expected length of random vector ||N(0, I)||
97    pub chi_n: f64,
98
99    /// Current generation
100    pub generation: usize,
101
102    /// Total evaluations
103    pub evaluations: usize,
104
105    /// Best fitness found
106    pub best_fitness: f64,
107
108    /// Best solution found
109    pub best_solution: Vec<f64>,
110}
111
112impl CmaEsState {
113    /// Create a new CMA-ES state
114    pub fn new(initial_mean: Vec<f64>, initial_sigma: f64, lambda: Option<usize>) -> Self {
115        let n = initial_mean.len();
116
117        // Default population size: 4 + floor(3 * ln(n))
118        let lambda = lambda.unwrap_or((4.0 + (3.0 * (n as f64).ln()).floor()) as usize);
119        let lambda = lambda.max(4); // Minimum 4
120
121        // Number of parents
122        let mu = lambda / 2;
123
124        // Recombination weights (log-linear)
125        let mut weights: Vec<f64> = (0..mu)
126            .map(|i| ((lambda as f64 + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
127            .collect();
128
129        // Normalize weights
130        let weight_sum: f64 = weights.iter().sum();
131        for w in &mut weights {
132            *w /= weight_sum;
133        }
134
135        // Variance effective selection mass
136        let mu_eff = 1.0 / weights.iter().map(|w| w * w).sum::<f64>();
137
138        // Strategy parameter settings
139        // Time constants for cumulation
140        let c_sigma = (mu_eff + 2.0) / (n as f64 + mu_eff + 5.0);
141        let c_c = (4.0 + mu_eff / n as f64) / (n as f64 + 4.0 + 2.0 * mu_eff / n as f64);
142
143        // Learning rates for covariance matrix update
144        let c_1 = 2.0 / ((n as f64 + 1.3).powi(2) + mu_eff);
145        let alpha_mu = 2.0;
146        let c_mu = (alpha_mu * (mu_eff - 2.0 + 1.0 / mu_eff))
147            / ((n as f64 + 2.0).powi(2) + alpha_mu * mu_eff / 2.0);
148        let c_mu = c_mu.min(1.0 - c_1); // Ensure c_1 + c_mu <= 1
149
150        // Damping for step-size
151        let d_sigma =
152            1.0 + 2.0 * (0.0_f64.max(((mu_eff - 1.0) / (n as f64 + 1.0)).sqrt() - 1.0)) + c_sigma;
153
154        // Expected length of a N(0,I) random vector
155        let chi_n =
156            (n as f64).sqrt() * (1.0 - 1.0 / (4.0 * n as f64) + 1.0 / (21.0 * (n as f64).powi(2)));
157
158        // Initialize covariance matrix to identity
159        let covariance: Vec<Vec<f64>> = (0..n)
160            .map(|i| {
161                let mut row = vec![0.0; n];
162                row[i] = 1.0;
163                row
164            })
165            .collect();
166
167        // Initialize eigenvalues and eigenvectors (identity)
168        let eigenvalues = vec![1.0; n];
169        let eigenvectors: Vec<Vec<f64>> = (0..n)
170            .map(|i| {
171                let mut row = vec![0.0; n];
172                row[i] = 1.0;
173                row
174            })
175            .collect();
176
177        Self {
178            mean: initial_mean.clone(),
179            sigma: initial_sigma,
180            covariance,
181            path_sigma: vec![0.0; n],
182            path_c: vec![0.0; n],
183            eigenvalues,
184            eigenvectors,
185            eigen_eval: 0,
186            dimension: n,
187            lambda,
188            mu,
189            weights,
190            mu_eff,
191            c_1,
192            c_mu,
193            c_sigma,
194            d_sigma,
195            c_c,
196            chi_n,
197            generation: 0,
198            evaluations: 0,
199            best_fitness: f64::INFINITY,
200            best_solution: initial_mean,
201        }
202    }
203
204    /// Sample lambda offspring from the current distribution
205    pub fn sample_population<R: Rng>(&self, rng: &mut R) -> Vec<RealVector> {
206        let n = self.dimension;
207        let normal = StandardNormal;
208
209        (0..self.lambda)
210            .map(|_| {
211                // Sample z ~ N(0, I)
212                let z: Vec<f64> = (0..n).map(|_| normal.sample(rng)).collect();
213
214                // Transform: y = B * D * z
215                let y: Vec<f64> = self.transform_sample(&z);
216
217                // x = m + σ * y
218                let genes: Vec<f64> = self
219                    .mean
220                    .iter()
221                    .zip(y.iter())
222                    .map(|(&m, &yi)| m + self.sigma * yi)
223                    .collect();
224
225                RealVector::new(genes)
226            })
227            .collect()
228    }
229
230    /// Transform a standard normal sample using the covariance structure
231    fn transform_sample(&self, z: &[f64]) -> Vec<f64> {
232        let n = self.dimension;
233        let mut y = vec![0.0; n];
234
235        // y = B * D * z where D = diag(sqrt(eigenvalues))
236        for i in 0..n {
237            for j in 0..n {
238                y[i] += self.eigenvectors[i][j] * self.eigenvalues[j].sqrt() * z[j];
239            }
240        }
241
242        y
243    }
244
245    /// Update the CMA-ES state based on evaluated offspring
246    ///
247    /// Offspring should be sorted by fitness (best first for minimization).
248    pub fn update(&mut self, offspring: &[(RealVector, f64)]) {
249        let n = self.dimension;
250
251        // Extract the best μ individuals
252        let selected: Vec<&(RealVector, f64)> = offspring.iter().take(self.mu).collect();
253
254        // Calculate weighted mean of selected steps (y values)
255        // y_w = Σ w_i * (x_i - m_old) / σ
256        let mut y_w = vec![0.0; n];
257        for (i, (genome, _fitness)) in selected.iter().enumerate() {
258            let genes = genome.genes();
259            for j in 0..n {
260                y_w[j] += self.weights[i] * (genes[j] - self.mean[j]) / self.sigma;
261            }
262        }
263
264        // Calculate B * D^-1 * y_w for path updates
265        let mut bd_inv_yw = vec![0.0; n];
266        {
267            // First compute D^-1 * B^T * y_w
268            let mut temp = vec![0.0; n];
269            for i in 0..n {
270                for j in 0..n {
271                    temp[i] += self.eigenvectors[j][i] * y_w[j];
272                }
273                temp[i] /= self.eigenvalues[i].sqrt().max(1e-16);
274            }
275            // Then compute B * temp
276            for i in 0..n {
277                for j in 0..n {
278                    bd_inv_yw[i] += self.eigenvectors[i][j] * temp[j];
279                }
280            }
281        }
282
283        // Update evolution path for sigma (p_σ)
284        let c_sigma_factor = (self.c_sigma * (2.0 - self.c_sigma) * self.mu_eff).sqrt();
285        for i in 0..n {
286            self.path_sigma[i] =
287                (1.0 - self.c_sigma) * self.path_sigma[i] + c_sigma_factor * bd_inv_yw[i];
288        }
289
290        // Calculate ||p_σ||²
291        let path_sigma_norm_sq: f64 = self.path_sigma.iter().map(|x| x * x).sum();
292        let path_sigma_norm = path_sigma_norm_sq.sqrt();
293
294        // Heaviside function for stall detection
295        let h_sigma = if path_sigma_norm
296            / (1.0 - (1.0 - self.c_sigma).powi((2 * (self.generation + 1)) as i32)).sqrt()
297            / self.chi_n
298            < 1.4 + 2.0 / (n as f64 + 1.0)
299        {
300            1.0
301        } else {
302            0.0
303        };
304
305        // Update evolution path for C (p_c)
306        let c_c_factor = (self.c_c * (2.0 - self.c_c) * self.mu_eff).sqrt();
307        for i in 0..n {
308            self.path_c[i] = (1.0 - self.c_c) * self.path_c[i] + h_sigma * c_c_factor * y_w[i];
309        }
310
311        // Update covariance matrix
312        let delta_h = (1.0 - h_sigma) * self.c_c * (2.0 - self.c_c);
313
314        for i in 0..n {
315            for j in 0..=i {
316                // Decay
317                self.covariance[i][j] *= 1.0 - self.c_1 - self.c_mu + delta_h * self.c_1;
318
319                // Rank-1 update
320                self.covariance[i][j] += self.c_1 * self.path_c[i] * self.path_c[j];
321
322                // Rank-μ update
323                for k in 0..self.mu {
324                    let y_k: Vec<f64> = selected[k]
325                        .0
326                        .genes()
327                        .iter()
328                        .zip(self.mean.iter())
329                        .map(|(&x, &m)| (x - m) / self.sigma)
330                        .collect();
331                    self.covariance[i][j] += self.c_mu * self.weights[k] * y_k[i] * y_k[j];
332                }
333
334                // Symmetry
335                if i != j {
336                    self.covariance[j][i] = self.covariance[i][j];
337                }
338            }
339        }
340
341        // Update mean
342        for i in 0..n {
343            self.mean[i] += self.sigma * y_w[i];
344        }
345
346        // Update sigma (step-size control)
347        self.sigma *= ((self.c_sigma / self.d_sigma) * (path_sigma_norm / self.chi_n - 1.0)).exp();
348
349        // Update generation counter
350        self.generation += 1;
351
352        // Update eigendecomposition on the lazy purecmaes cadence (expensive, so
353        // don't do it every generation). Best-solution tracking is handled by the
354        // caller (`step`) so that it records the *feasible* (bound-repaired)
355        // point rather than the raw sample used for the distribution update.
356        if self.generation - self.eigen_eval >= self.eigen_update_interval() {
357            self.update_eigensystem();
358            self.eigen_eval = self.generation;
359        }
360    }
361
362    /// Number of generations between eigendecomposition recomputes.
363    ///
364    /// purecmaes recomputes the eigensystem when
365    /// `counteval - eigeneval > lambda/(c1+cmu)/N/10`, where `counteval` counts
366    /// **evaluations** and advances by `lambda` each generation. In generation
367    /// units this is `generation - eigen_eval > 1/(10·N·(c1+cmu))`, i.e. the
368    /// interval is `max(1, floor(1/(10·N·(c1+cmu))))`. The previous code compared
369    /// a *generation* difference against the *evaluation*-scaled right-hand side,
370    /// making recomputes a factor of λ too infrequent (see AUDIT EV-37).
371    pub fn eigen_update_interval(&self) -> usize {
372        let n = self.dimension as f64;
373        ((1.0 / (10.0 * n * (self.c_1 + self.c_mu))).floor() as usize).max(1)
374    }
375
376    /// Update eigendecomposition of C
377    fn update_eigensystem(&mut self) {
378        let n = self.dimension;
379
380        // Force symmetry
381        for i in 0..n {
382            for j in 0..i {
383                self.covariance[i][j] = (self.covariance[i][j] + self.covariance[j][i]) / 2.0;
384                self.covariance[j][i] = self.covariance[i][j];
385            }
386        }
387
388        // Symmetric eigendecomposition via nalgebra. `eigenvalues[j]` is paired
389        // with column `j` of `eigenvectors` (matching the B·D·z sampling and
390        // C^{-1/2} = B·D^{-1}·Bᵀ conventions used below).
391        let (eigenvalues, eigenvectors) = symmetric_eigendecomposition(&self.covariance);
392
393        self.eigenvalues = eigenvalues;
394        self.eigenvectors = eigenvectors;
395
396        // Ensure eigenvalues are positive (numerical stability)
397        for ev in &mut self.eigenvalues {
398            *ev = ev.max(1e-16);
399        }
400    }
401
402    /// Check if algorithm has converged
403    pub fn has_converged(&self) -> bool {
404        // Check for various convergence criteria
405        let max_eigenvalue = self
406            .eigenvalues
407            .iter()
408            .cloned()
409            .fold(f64::NEG_INFINITY, f64::max);
410        let min_eigenvalue = self
411            .eigenvalues
412            .iter()
413            .cloned()
414            .fold(f64::INFINITY, f64::min);
415
416        // Condition number too large
417        if max_eigenvalue / min_eigenvalue.max(1e-16) > 1e14 {
418            return true;
419        }
420
421        // Sigma too small
422        if self.sigma < 1e-16 {
423            return true;
424        }
425
426        // Sigma times max standard deviation very small
427        if self.sigma * max_eigenvalue.sqrt() < 1e-16 {
428            return true;
429        }
430
431        false
432    }
433}
434
435/// Symmetric eigendecomposition of a real symmetric matrix.
436///
437/// Returns `(eigenvalues, eigenvectors)` where `eigenvalues[j]` is the
438/// eigenvalue associated with **column `j`** of the returned `eigenvectors`
439/// matrix (i.e. `eigenvectors[i][j]` is the `i`-th component of the `j`-th
440/// eigenvector). Eigenvectors form an orthonormal set, and for the CMA-ES
441/// covariance `C` the reconstruction `B · diag(λ) · Bᵀ = C` holds.
442///
443/// Backed by `nalgebra::SymmetricEigen`, which uses a numerically stable
444/// symmetric tridiagonalization + implicit-shift QR iteration rather than the
445/// previous hand-rolled cyclic-Jacobi routine (which never mutated its input
446/// matrix and therefore returned incorrect — even negative — eigenvalues and
447/// non-diagonalizing eigenvectors; see AUDIT EV-01).
448fn symmetric_eigendecomposition(a: &[Vec<f64>]) -> (Vec<f64>, Vec<Vec<f64>>) {
449    let n = a.len();
450
451    // nalgebra stores column-major; build from a row-major flat copy of `a`.
452    let flat: Vec<f64> = (0..n).flat_map(|i| (0..n).map(move |j| a[i][j])).collect();
453    let matrix = DMatrix::from_row_slice(n, n, &flat);
454
455    let eig = SymmetricEigen::new(matrix);
456
457    let eigenvalues: Vec<f64> = eig.eigenvalues.iter().copied().collect();
458    let eigenvectors: Vec<Vec<f64>> = (0..n)
459        .map(|i| (0..n).map(|j| eig.eigenvectors[(i, j)]).collect())
460        .collect();
461
462    (eigenvalues, eigenvectors)
463}
464
465/// Implement CmaEsFitness for any Fn that matches the signature
466#[cfg(feature = "parallel")]
467impl<F> CmaEsFitness for F
468where
469    F: Fn(&RealVector) -> f64 + Send + Sync,
470{
471    fn evaluate(&self, x: &RealVector) -> f64 {
472        self(x)
473    }
474}
475
476/// Implement CmaEsFitness for any Fn that matches the signature
477#[cfg(not(feature = "parallel"))]
478impl<F> CmaEsFitness for F
479where
480    F: Fn(&RealVector) -> f64,
481{
482    fn evaluate(&self, x: &RealVector) -> f64 {
483        self(x)
484    }
485}
486
487/// CMA-ES optimizer
488#[derive(Clone)]
489pub struct CmaEs<F> {
490    /// State of the optimizer
491    pub state: CmaEsState,
492    /// Problem bounds
493    pub bounds: Option<MultiBounds>,
494    /// Weight of the optional quadratic boundary penalty (0.0 = disabled).
495    ///
496    /// When positive, an infeasible sample `x` is scored as
497    /// `f(clamp(x)) + boundary_penalty · ‖x − clamp(x)‖²`, following Hansen's
498    /// reference boundary handling. The distribution (mean/covariance/paths) is
499    /// always adapted from the *unrepaired* sample regardless of this weight.
500    pub boundary_penalty: f64,
501    /// Fitness function marker
502    _phantom: PhantomData<F>,
503}
504
505impl<F: CmaEsFitness> CmaEs<F> {
506    /// Create a new CMA-ES optimizer
507    pub fn new(initial_mean: Vec<f64>, initial_sigma: f64) -> Self {
508        Self {
509            state: CmaEsState::new(initial_mean, initial_sigma, None),
510            bounds: None,
511            boundary_penalty: 0.0,
512            _phantom: PhantomData,
513        }
514    }
515
516    /// Create with custom population size
517    pub fn with_lambda(initial_mean: Vec<f64>, initial_sigma: f64, lambda: usize) -> Self {
518        Self {
519            state: CmaEsState::new(initial_mean, initial_sigma, Some(lambda)),
520            bounds: None,
521            boundary_penalty: 0.0,
522            _phantom: PhantomData,
523        }
524    }
525
526    /// Set problem bounds
527    pub fn with_bounds(mut self, bounds: MultiBounds) -> Self {
528        self.bounds = Some(bounds);
529        self
530    }
531
532    /// Set the quadratic boundary-penalty weight (see [`CmaEs::boundary_penalty`]).
533    pub fn with_boundary_penalty(mut self, weight: f64) -> Self {
534        self.boundary_penalty = weight;
535        self
536    }
537
538    /// Run a single generation
539    ///
540    /// Box constraints are handled per Hansen's recommendation (AUDIT EV-36):
541    /// each sample is evaluated at its *repaired* (bound-clamped, and therefore
542    /// feasible) position — optionally with a quadratic infeasibility penalty —
543    /// but the search distribution (mean, covariance, evolution paths, step
544    /// size) is adapted from the **unrepaired** sample `y = (x − m)/σ`. Feeding
545    /// repaired points back into the distribution update biases `C` and `m`
546    /// toward active bounds; keeping the raw sample avoids that bias.
547    pub fn step<R: Rng>(
548        &mut self,
549        fitness: &F,
550        rng: &mut R,
551    ) -> EvoResult<Vec<Individual<RealVector>>> {
552        // Sample offspring (unrepaired). These raw samples drive the update.
553        let unrepaired = self.state.sample_population(rng);
554
555        // For each sample: build the feasible (repaired) point, evaluate fitness
556        // there, and add the optional quadratic boundary penalty. Keep the
557        // unrepaired sample for the distribution update.
558        let mut evaluated: Vec<(RealVector, RealVector, f64)> = unrepaired
559            .into_iter()
560            .map(|raw| {
561                let feasible = match self.bounds {
562                    Some(ref bounds) => {
563                        let mut repaired = raw.clone();
564                        repaired.apply_bounds(bounds);
565                        repaired
566                    }
567                    None => raw.clone(),
568                };
569
570                let mut f = fitness.evaluate(&feasible);
571                if self.boundary_penalty > 0.0 {
572                    let penalty: f64 = raw
573                        .genes()
574                        .iter()
575                        .zip(feasible.genes().iter())
576                        .map(|(&x, &c)| {
577                            let d = x - c;
578                            d * d
579                        })
580                        .sum();
581                    f += self.boundary_penalty * penalty;
582                }
583
584                (raw, feasible, f)
585            })
586            .collect();
587
588        self.state.evaluations += evaluated.len();
589
590        // Sort by fitness (minimization).
591        evaluated.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
592
593        // Update the distribution from the UNREPAIRED samples.
594        let update_input: Vec<(RealVector, f64)> = evaluated
595            .iter()
596            .map(|(raw, _feasible, f)| (raw.clone(), *f))
597            .collect();
598        self.state.update(&update_input);
599
600        // Track the best *feasible* solution.
601        if let Some((_, feasible, f)) = evaluated.first() {
602            if *f < self.state.best_fitness {
603                self.state.best_fitness = *f;
604                self.state.best_solution = feasible.genes().to_vec();
605            }
606        }
607
608        // Convert to individuals using the feasible points.
609        let individuals: Vec<Individual<RealVector>> = evaluated
610            .into_iter()
611            .map(|(_raw, feasible, f)| Individual::with_fitness(feasible, f))
612            .collect();
613
614        Ok(individuals)
615    }
616
617    /// Run the optimizer for a fixed number of generations
618    pub fn run_generations<R: Rng>(
619        &mut self,
620        fitness: &F,
621        max_generations: usize,
622        rng: &mut R,
623    ) -> EvoResult<Individual<RealVector>> {
624        let mut best: Option<Individual<RealVector>> = None;
625
626        for _ in 0..max_generations {
627            let population = self.step(fitness, rng)?;
628
629            // Track best
630            if let Some(current_best) = population.first() {
631                match &best {
632                    None => best = Some(current_best.clone()),
633                    Some(existing) => {
634                        if current_best.fitness_f64() < existing.fitness_f64() {
635                            best = Some(current_best.clone());
636                        }
637                    }
638                }
639            }
640
641            // Check internal convergence
642            if self.state.has_converged() {
643                break;
644            }
645        }
646
647        best.ok_or(EvolutionError::EmptyPopulation)
648    }
649
650    /// Run the optimizer until a target fitness is reached or max generations
651    pub fn run_until<R: Rng>(
652        &mut self,
653        fitness: &F,
654        target_fitness: f64,
655        max_generations: usize,
656        rng: &mut R,
657    ) -> EvoResult<Individual<RealVector>> {
658        let mut best: Option<Individual<RealVector>> = None;
659
660        for _ in 0..max_generations {
661            let population = self.step(fitness, rng)?;
662
663            // Track best
664            if let Some(current_best) = population.first() {
665                match &best {
666                    None => best = Some(current_best.clone()),
667                    Some(existing) => {
668                        if current_best.fitness_f64() < existing.fitness_f64() {
669                            best = Some(current_best.clone());
670                        }
671                    }
672                }
673            }
674
675            // Check target fitness
676            if let Some(ref b) = best {
677                if b.fitness_f64() <= target_fitness {
678                    break;
679                }
680            }
681
682            // Check internal convergence
683            if self.state.has_converged() {
684                break;
685            }
686        }
687
688        best.ok_or(EvolutionError::EmptyPopulation)
689    }
690
691    /// Get the current generation
692    pub fn generation(&self) -> usize {
693        self.state.generation
694    }
695
696    /// Get total evaluations
697    pub fn evaluations(&self) -> usize {
698        self.state.evaluations
699    }
700
701    /// Get current mean
702    pub fn mean(&self) -> &[f64] {
703        &self.state.mean
704    }
705
706    /// Get current sigma
707    pub fn sigma(&self) -> f64 {
708        self.state.sigma
709    }
710
711    /// Get the best solution found
712    pub fn best_solution(&self) -> &[f64] {
713        &self.state.best_solution
714    }
715
716    /// Get the best fitness found
717    pub fn best_fitness(&self) -> f64 {
718        self.state.best_fitness
719    }
720}
721
722/// Builder for CMA-ES
723pub struct CmaEsBuilder {
724    initial_mean: Option<Vec<f64>>,
725    initial_sigma: f64,
726    lambda: Option<usize>,
727    bounds: Option<MultiBounds>,
728    boundary_penalty: f64,
729}
730
731impl CmaEsBuilder {
732    /// Create a new builder
733    pub fn new() -> Self {
734        Self {
735            initial_mean: None,
736            initial_sigma: 1.0,
737            lambda: None,
738            bounds: None,
739            boundary_penalty: 0.0,
740        }
741    }
742
743    /// Set the quadratic boundary-penalty weight (see [`CmaEs::boundary_penalty`]).
744    pub fn boundary_penalty(mut self, weight: f64) -> Self {
745        self.boundary_penalty = weight;
746        self
747    }
748
749    /// Set the initial mean
750    pub fn mean(mut self, mean: Vec<f64>) -> Self {
751        self.initial_mean = Some(mean);
752        self
753    }
754
755    /// Set the initial step size (sigma)
756    pub fn sigma(mut self, sigma: f64) -> Self {
757        self.initial_sigma = sigma;
758        self
759    }
760
761    /// Set the population size
762    pub fn lambda(mut self, lambda: usize) -> Self {
763        self.lambda = Some(lambda);
764        self
765    }
766
767    /// Set the bounds
768    pub fn bounds(mut self, bounds: MultiBounds) -> Self {
769        self.bounds = Some(bounds);
770        self
771    }
772
773    /// Build the CMA-ES optimizer
774    pub fn build<F: CmaEsFitness>(self) -> EvoResult<CmaEs<F>> {
775        let mean = self
776            .initial_mean
777            .ok_or_else(|| EvolutionError::Configuration("Initial mean not set".to_string()))?;
778
779        let mut cmaes = match self.lambda {
780            Some(l) => CmaEs::with_lambda(mean, self.initial_sigma, l),
781            None => CmaEs::new(mean, self.initial_sigma),
782        };
783
784        if let Some(bounds) = self.bounds {
785            cmaes = cmaes.with_bounds(bounds);
786        }
787
788        cmaes.boundary_penalty = self.boundary_penalty;
789
790        Ok(cmaes)
791    }
792}
793
794impl Default for CmaEsBuilder {
795    fn default() -> Self {
796        Self::new()
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::genome::traits::EvolutionaryGenome;
804    use approx::assert_relative_eq;
805
806    // Simple sphere function for testing (minimization: lower is better)
807    struct Sphere;
808
809    impl CmaEsFitness for Sphere {
810        fn evaluate(&self, genome: &RealVector) -> f64 {
811            genome.genes().iter().map(|x| x * x).sum()
812        }
813    }
814
815    #[test]
816    fn test_cmaes_state_initialization() {
817        let mean = vec![0.0, 0.0, 0.0];
818        let state = CmaEsState::new(mean.clone(), 1.0, None);
819
820        assert_eq!(state.dimension, 3);
821        assert_eq!(state.mean, mean);
822        assert_eq!(state.sigma, 1.0);
823        assert!(state.lambda >= 4);
824        assert!(state.mu > 0);
825        assert_eq!(state.weights.len(), state.mu);
826    }
827
828    #[test]
829    fn test_cmaes_weights_sum_to_one() {
830        let state = CmaEsState::new(vec![0.0; 10], 1.0, None);
831        let sum: f64 = state.weights.iter().sum();
832        assert_relative_eq!(sum, 1.0, epsilon = 1e-10);
833    }
834
835    #[test]
836    fn test_cmaes_sampling() {
837        let mut rng = rand::thread_rng();
838        let state = CmaEsState::new(vec![0.0; 5], 1.0, Some(10));
839
840        let samples = state.sample_population(&mut rng);
841
842        assert_eq!(samples.len(), 10);
843        for sample in &samples {
844            assert_eq!(sample.dimension(), 5);
845        }
846    }
847
848    #[test]
849    fn test_cmaes_step() {
850        let mut rng = rand::thread_rng();
851        let fitness = Sphere;
852        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![5.0, 5.0, 5.0], 2.0);
853
854        let population = cmaes.step(&fitness, &mut rng).unwrap();
855
856        assert_eq!(population.len(), cmaes.state.lambda);
857        assert_eq!(cmaes.state.generation, 1);
858    }
859
860    #[test]
861    fn test_cmaes_optimization() {
862        use rand::SeedableRng;
863        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
864        let fitness = Sphere;
865        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![5.0, 5.0], 2.0);
866
867        // Run for more generations to allow convergence
868        let result = cmaes.run_generations(&fitness, 150, &mut rng).unwrap();
869
870        // CMA-ES should find solution close to origin
871        // Starting from [5,5] (fitness=50), should improve significantly
872        let final_fitness = result.fitness_f64();
873        let initial_fitness = 50.0; // 5^2 + 5^2
874        assert!(
875            final_fitness < initial_fitness * 0.7,
876            "Final fitness {} should be significantly better than initial {}",
877            final_fitness,
878            initial_fitness
879        );
880    }
881
882    #[test]
883    fn test_cmaes_with_bounds() {
884        let mut rng = rand::thread_rng();
885        let fitness = Sphere;
886        let bounds = MultiBounds::symmetric(10.0, 3);
887
888        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![5.0, 5.0, 5.0], 2.0).with_bounds(bounds);
889
890        let result = cmaes.run_generations(&fitness, 30, &mut rng).unwrap();
891
892        // Check all genes are within bounds
893        for gene in result.genome().genes() {
894            assert!(*gene >= -10.0 && *gene <= 10.0);
895        }
896    }
897
898    #[test]
899    fn test_cmaes_builder() {
900        let cmaes: CmaEs<Sphere> = CmaEsBuilder::new()
901            .mean(vec![0.0, 0.0, 0.0])
902            .sigma(0.5)
903            .lambda(20)
904            .bounds(MultiBounds::symmetric(5.0, 3))
905            .build()
906            .unwrap();
907
908        assert_eq!(cmaes.state.lambda, 20);
909        assert_eq!(cmaes.state.sigma, 0.5);
910        assert!(cmaes.bounds.is_some());
911    }
912
913    #[test]
914    fn test_cmaes_convergence_detection() {
915        let mut state = CmaEsState::new(vec![0.0, 0.0], 1e-20, None);
916        assert!(state.has_converged());
917
918        state.sigma = 1.0;
919        state.eigenvalues = vec![1e15, 1.0];
920        assert!(state.has_converged());
921    }
922
923    // Rosenbrock (minimization, optimum f=0 at all-ones) for CMA-ES behavioural
924    // testing. Defined directly (not via the benchmark type, which negates for
925    // maximization) so it matches CMA-ES's lower-is-better convention.
926    fn rosenbrock(x: &RealVector) -> f64 {
927        x.genes()
928            .windows(2)
929            .map(|w| 100.0 * (w[1] - w[0] * w[0]).powi(2) + (1.0 - w[0]).powi(2))
930            .sum()
931    }
932
933    // Build a random symmetric positive-definite matrix C = A·Aᵀ + n·I.
934    fn random_spd(n: usize, rng: &mut impl Rng) -> Vec<Vec<f64>> {
935        let a: Vec<Vec<f64>> = (0..n)
936            .map(|_| {
937                (0..n)
938                    .map(|_| rng.gen_range(-1.0..1.0))
939                    .collect::<Vec<f64>>()
940            })
941            .collect();
942        let mut c = vec![vec![0.0; n]; n];
943        for (i, ci) in c.iter_mut().enumerate() {
944            for (j, cij) in ci.iter_mut().enumerate() {
945                let mut s = 0.0;
946                for k in 0..n {
947                    s += a[i][k] * a[j][k];
948                }
949                *cij = s;
950            }
951        }
952        for (i, ci) in c.iter_mut().enumerate() {
953            ci[i] += n as f64;
954        }
955        c
956    }
957
958    /// regression: EV-01 / EV-38 — the eigendecomposition must return the true
959    /// eigenvalues (all positive for an SPD matrix), eigenvectors that actually
960    /// diagonalize C (C·vᵢ = λᵢ·vᵢ), and reconstruct C via B·diag(λ)·Bᵀ. The
961    /// pre-fix cyclic-Jacobi routine never mutated its input, so it returned
962    /// wrong (even negative) eigenvalues while only preserving the trace — the
963    /// old test asserted only Σλ = trace, which the broken routine passed.
964    #[test]
965    fn test_eigendecomposition_known_matrix() {
966        let a = vec![vec![4.0, 1.0], vec![1.0, 3.0]];
967        let (eigenvalues, eigenvectors) = symmetric_eigendecomposition(&a);
968
969        // Eigenvalues of [[4,1],[1,3]] are (7±√5)/2 ≈ 4.618 and 2.382.
970        let mut sorted = eigenvalues.clone();
971        sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
972        assert_relative_eq!(sorted[0], (7.0 - 5.0_f64.sqrt()) / 2.0, epsilon = 1e-9);
973        assert_relative_eq!(sorted[1], (7.0 + 5.0_f64.sqrt()) / 2.0, epsilon = 1e-9);
974
975        // All eigenvalues of this PD matrix are positive.
976        assert!(eigenvalues.iter().all(|&l| l > 0.0));
977
978        // Each (λ_j, column j) is an eigenpair: C·v = λ·v.
979        for j in 0..2 {
980            let v: Vec<f64> = (0..2).map(|i| eigenvectors[i][j]).collect();
981            for i in 0..2 {
982                let cv: f64 = (0..2).map(|k| a[i][k] * v[k]).sum();
983                assert_relative_eq!(cv, eigenvalues[j] * v[i], epsilon = 1e-9);
984            }
985        }
986    }
987
988    /// regression: EV-01 — on random SPD matrices, every returned pair must
989    /// satisfy C·vᵢ = λᵢ·vᵢ to 1e-9, all eigenvalues must be positive, and
990    /// B·diag(λ)·Bᵀ must reconstruct C.
991    #[test]
992    fn test_eigendecomposition_random_spd() {
993        use rand::SeedableRng;
994        let mut rng = rand::rngs::StdRng::seed_from_u64(2024);
995
996        for &n in &[2usize, 3, 5, 8] {
997            let c = random_spd(n, &mut rng);
998            let (eigenvalues, eigenvectors) = symmetric_eigendecomposition(&c);
999
1000            // Positivity.
1001            assert!(
1002                eigenvalues.iter().all(|&l| l > 0.0),
1003                "SPD matrix must have all-positive eigenvalues, got {:?}",
1004                eigenvalues
1005            );
1006
1007            // Eigenpair equation C·vⱼ = λⱼ·vⱼ.
1008            for j in 0..n {
1009                let v: Vec<f64> = (0..n).map(|i| eigenvectors[i][j]).collect();
1010                for i in 0..n {
1011                    let cv: f64 = (0..n).map(|k| c[i][k] * v[k]).sum();
1012                    assert!(
1013                        (cv - eigenvalues[j] * v[i]).abs() < 1e-9,
1014                        "n={}: C·v_{} component {} mismatch",
1015                        n,
1016                        j,
1017                        i
1018                    );
1019                }
1020            }
1021
1022            // Reconstruction B·diag(λ)·Bᵀ = C.
1023            for i in 0..n {
1024                for j in 0..n {
1025                    let recon: f64 = (0..n)
1026                        .map(|k| eigenvectors[i][k] * eigenvalues[k] * eigenvectors[j][k])
1027                        .sum();
1028                    assert!(
1029                        (recon - c[i][j]).abs() < 1e-9,
1030                        "n={}: reconstruction mismatch at ({},{})",
1031                        n,
1032                        i,
1033                        j
1034                    );
1035                }
1036            }
1037        }
1038    }
1039
1040    /// regression: EV-01 (behavioural) — with a correct eigendecomposition,
1041    /// CMA-ES solves the 5-D Rosenbrock function to f < 1e-6 within a generous
1042    /// seeded budget. The pre-fix garbage eigensystem corrupted sampling and
1043    /// C^{-1/2}, so it could not converge.
1044    #[test]
1045    fn test_cmaes_rosenbrock_convergence() {
1046        use rand::SeedableRng;
1047        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
1048
1049        // Slightly larger λ improves robustness on the Rosenbrock valley.
1050        let mut cmaes: CmaEs<_> = CmaEs::with_lambda(vec![0.0; 5], 0.5, 20);
1051        let best = cmaes.run_until(&rosenbrock, 1e-6, 4000, &mut rng).unwrap();
1052
1053        assert!(
1054            best.fitness_f64() < 1e-6,
1055            "CMA-ES should reach f < 1e-6 on 5-D Rosenbrock, got {}",
1056            best.fitness_f64()
1057        );
1058    }
1059
1060    /// regression: EV-37 — for large n the correct per-generation cadence
1061    /// recomputes the eigensystem (nearly) every generation, whereas the pre-fix
1062    /// λ-too-infrequent cadence recomputed only a handful of times.
1063    #[test]
1064    fn test_eigen_recompute_cadence() {
1065        use rand::SeedableRng;
1066        let mut rng = rand::rngs::StdRng::seed_from_u64(3);
1067        let fitness = Sphere;
1068        let n = 100;
1069        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![1.0; n], 1.0);
1070
1071        // For n=100 the correct interval is 1 generation.
1072        assert_eq!(cmaes.state.eigen_update_interval(), 1);
1073
1074        let gens = 30;
1075        let mut recomputes = 0;
1076        let mut last = cmaes.state.eigen_eval;
1077        for _ in 0..gens {
1078            cmaes.step(&fitness, &mut rng).unwrap();
1079            if cmaes.state.eigen_eval != last {
1080                recomputes += 1;
1081                last = cmaes.state.eigen_eval;
1082            }
1083        }
1084
1085        assert!(
1086            recomputes >= gens - 1,
1087            "expected ~every-generation eigen recompute for n=100, got {} in {} gens",
1088            recomputes,
1089            gens
1090        );
1091    }
1092
1093    /// regression: EV-36 — the distribution update must be adapted from the
1094    /// UNREPAIRED samples. With bounds sitting far above the sampling region,
1095    /// every sample clamps up to the lower bound, so a clamped-update (pre-fix)
1096    /// would slam the mean straight onto the boundary (~2.9) in a single step,
1097    /// while an unrepaired update keeps the mean near the sampled region.
1098    #[test]
1099    fn test_cmaes_update_uses_unrepaired_samples() {
1100        use crate::genome::bounds::Bounds;
1101        use rand::SeedableRng;
1102        let mut rng = rand::rngs::StdRng::seed_from_u64(11);
1103        let fitness = Sphere;
1104
1105        let bounds = MultiBounds::uniform(Bounds::new(2.9, 3.1), 2);
1106        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![0.0, 0.0], 1.0).with_bounds(bounds);
1107
1108        cmaes.step(&fitness, &mut rng).unwrap();
1109
1110        for &m in &cmaes.state.mean {
1111            assert!(
1112                m.abs() < 2.0,
1113                "mean component {} was pulled onto the clamped boundary (~2.9); \
1114                 the distribution update must use unrepaired samples",
1115                m
1116            );
1117        }
1118
1119        // The best solution reported must still be feasible (within bounds).
1120        for &g in &cmaes.state.best_solution {
1121            assert!((2.9..=3.1).contains(&g));
1122        }
1123    }
1124
1125    /// The optional quadratic boundary penalty adds a positive infeasibility
1126    /// cost while leaving the feasible-region behaviour unchanged when disabled.
1127    #[test]
1128    fn test_cmaes_boundary_penalty_option() {
1129        use crate::genome::bounds::Bounds;
1130        use rand::SeedableRng;
1131        let mut rng = rand::rngs::StdRng::seed_from_u64(5);
1132        let fitness = Sphere;
1133
1134        let bounds = MultiBounds::uniform(Bounds::new(-1.0, 1.0), 3);
1135        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![0.0; 3], 2.0)
1136            .with_bounds(bounds)
1137            .with_boundary_penalty(10.0);
1138        assert_eq!(cmaes.boundary_penalty, 10.0);
1139
1140        // Should run without panicking and keep the reported best feasible.
1141        cmaes.step(&fitness, &mut rng).unwrap();
1142        for &g in &cmaes.state.best_solution {
1143            assert!((-1.0..=1.0).contains(&g));
1144        }
1145    }
1146}