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 rand::Rng;
12use rand_distr::{Distribution, StandardNormal};
13
14use crate::error::{EvoResult, EvolutionError};
15use crate::genome::bounds::MultiBounds;
16use crate::genome::real_vector::RealVector;
17use crate::genome::traits::RealValuedGenome;
18use crate::population::individual::Individual;
19
20/// Trait for fitness functions used with CMA-ES
21///
22/// CMA-ES is a minimization algorithm, so lower values are better.
23#[cfg(feature = "parallel")]
24pub trait CmaEsFitness: Send + Sync {
25    /// Evaluate the fitness of a solution (lower is better)
26    fn evaluate(&self, x: &RealVector) -> f64;
27}
28
29/// Trait for fitness functions used with CMA-ES
30///
31/// CMA-ES is a minimization algorithm, so lower values are better.
32#[cfg(not(feature = "parallel"))]
33pub trait CmaEsFitness {
34    /// Evaluate the fitness of a solution (lower is better)
35    fn evaluate(&self, x: &RealVector) -> f64;
36}
37
38/// CMA-ES state containing all adaptation parameters
39#[derive(Clone, Debug)]
40pub struct CmaEsState {
41    /// Current mean of the search distribution
42    pub mean: Vec<f64>,
43
44    /// Global step size (σ)
45    pub sigma: f64,
46
47    /// Covariance matrix C (stored as upper triangular for efficiency)
48    pub covariance: Vec<Vec<f64>>,
49
50    /// Evolution path for σ adaptation (p_σ)
51    pub path_sigma: Vec<f64>,
52
53    /// Evolution path for C adaptation (p_c)
54    pub path_c: Vec<f64>,
55
56    /// Eigenvalues of C (D²)
57    pub eigenvalues: Vec<f64>,
58
59    /// Eigenvectors of C (B)
60    pub eigenvectors: Vec<Vec<f64>>,
61
62    /// Generation counter for eigendecomposition
63    pub eigen_eval: usize,
64
65    /// Problem dimension
66    pub dimension: usize,
67
68    /// Population size (λ)
69    pub lambda: usize,
70
71    /// Parent number (μ)
72    pub mu: usize,
73
74    /// Recombination weights
75    pub weights: Vec<f64>,
76
77    /// Variance effective selection mass (μ_eff)
78    pub mu_eff: f64,
79
80    /// Learning rate for rank-1 update
81    pub c_1: f64,
82
83    /// Learning rate for rank-μ update
84    pub c_mu: f64,
85
86    /// Learning rate for cumulation for σ control
87    pub c_sigma: f64,
88
89    /// Damping for σ
90    pub d_sigma: f64,
91
92    /// Learning rate for cumulation for C
93    pub c_c: f64,
94
95    /// Expected length of random vector ||N(0, I)||
96    pub chi_n: f64,
97
98    /// Current generation
99    pub generation: usize,
100
101    /// Total evaluations
102    pub evaluations: usize,
103
104    /// Best fitness found
105    pub best_fitness: f64,
106
107    /// Best solution found
108    pub best_solution: Vec<f64>,
109}
110
111impl CmaEsState {
112    /// Create a new CMA-ES state
113    pub fn new(initial_mean: Vec<f64>, initial_sigma: f64, lambda: Option<usize>) -> Self {
114        let n = initial_mean.len();
115
116        // Default population size: 4 + floor(3 * ln(n))
117        let lambda = lambda.unwrap_or((4.0 + (3.0 * (n as f64).ln()).floor()) as usize);
118        let lambda = lambda.max(4); // Minimum 4
119
120        // Number of parents
121        let mu = lambda / 2;
122
123        // Recombination weights (log-linear)
124        let mut weights: Vec<f64> = (0..mu)
125            .map(|i| ((lambda as f64 + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
126            .collect();
127
128        // Normalize weights
129        let weight_sum: f64 = weights.iter().sum();
130        for w in &mut weights {
131            *w /= weight_sum;
132        }
133
134        // Variance effective selection mass
135        let mu_eff = 1.0 / weights.iter().map(|w| w * w).sum::<f64>();
136
137        // Strategy parameter settings
138        // Time constants for cumulation
139        let c_sigma = (mu_eff + 2.0) / (n as f64 + mu_eff + 5.0);
140        let c_c = (4.0 + mu_eff / n as f64) / (n as f64 + 4.0 + 2.0 * mu_eff / n as f64);
141
142        // Learning rates for covariance matrix update
143        let c_1 = 2.0 / ((n as f64 + 1.3).powi(2) + mu_eff);
144        let alpha_mu = 2.0;
145        let c_mu = (alpha_mu * (mu_eff - 2.0 + 1.0 / mu_eff))
146            / ((n as f64 + 2.0).powi(2) + alpha_mu * mu_eff / 2.0);
147        let c_mu = c_mu.min(1.0 - c_1); // Ensure c_1 + c_mu <= 1
148
149        // Damping for step-size
150        let d_sigma =
151            1.0 + 2.0 * (0.0_f64.max(((mu_eff - 1.0) / (n as f64 + 1.0)).sqrt() - 1.0)) + c_sigma;
152
153        // Expected length of a N(0,I) random vector
154        let chi_n =
155            (n as f64).sqrt() * (1.0 - 1.0 / (4.0 * n as f64) + 1.0 / (21.0 * (n as f64).powi(2)));
156
157        // Initialize covariance matrix to identity
158        let covariance: Vec<Vec<f64>> = (0..n)
159            .map(|i| {
160                let mut row = vec![0.0; n];
161                row[i] = 1.0;
162                row
163            })
164            .collect();
165
166        // Initialize eigenvalues and eigenvectors (identity)
167        let eigenvalues = vec![1.0; n];
168        let eigenvectors: Vec<Vec<f64>> = (0..n)
169            .map(|i| {
170                let mut row = vec![0.0; n];
171                row[i] = 1.0;
172                row
173            })
174            .collect();
175
176        Self {
177            mean: initial_mean.clone(),
178            sigma: initial_sigma,
179            covariance,
180            path_sigma: vec![0.0; n],
181            path_c: vec![0.0; n],
182            eigenvalues,
183            eigenvectors,
184            eigen_eval: 0,
185            dimension: n,
186            lambda,
187            mu,
188            weights,
189            mu_eff,
190            c_1,
191            c_mu,
192            c_sigma,
193            d_sigma,
194            c_c,
195            chi_n,
196            generation: 0,
197            evaluations: 0,
198            best_fitness: f64::INFINITY,
199            best_solution: initial_mean,
200        }
201    }
202
203    /// Sample lambda offspring from the current distribution
204    pub fn sample_population<R: Rng>(&self, rng: &mut R) -> Vec<RealVector> {
205        let n = self.dimension;
206        let normal = StandardNormal;
207
208        (0..self.lambda)
209            .map(|_| {
210                // Sample z ~ N(0, I)
211                let z: Vec<f64> = (0..n).map(|_| normal.sample(rng)).collect();
212
213                // Transform: y = B * D * z
214                let y: Vec<f64> = self.transform_sample(&z);
215
216                // x = m + σ * y
217                let genes: Vec<f64> = self
218                    .mean
219                    .iter()
220                    .zip(y.iter())
221                    .map(|(&m, &yi)| m + self.sigma * yi)
222                    .collect();
223
224                RealVector::new(genes)
225            })
226            .collect()
227    }
228
229    /// Transform a standard normal sample using the covariance structure
230    fn transform_sample(&self, z: &[f64]) -> Vec<f64> {
231        let n = self.dimension;
232        let mut y = vec![0.0; n];
233
234        // y = B * D * z where D = diag(sqrt(eigenvalues))
235        for i in 0..n {
236            for j in 0..n {
237                y[i] += self.eigenvectors[i][j] * self.eigenvalues[j].sqrt() * z[j];
238            }
239        }
240
241        y
242    }
243
244    /// Update the CMA-ES state based on evaluated offspring
245    ///
246    /// Offspring should be sorted by fitness (best first for minimization).
247    pub fn update(&mut self, offspring: &[(RealVector, f64)]) {
248        let n = self.dimension;
249
250        // Extract the best μ individuals
251        let selected: Vec<&(RealVector, f64)> = offspring.iter().take(self.mu).collect();
252
253        // Calculate weighted mean of selected steps (y values)
254        // y_w = Σ w_i * (x_i - m_old) / σ
255        let mut y_w = vec![0.0; n];
256        for (i, (genome, _fitness)) in selected.iter().enumerate() {
257            let genes = genome.genes();
258            for j in 0..n {
259                y_w[j] += self.weights[i] * (genes[j] - self.mean[j]) / self.sigma;
260            }
261        }
262
263        // Calculate B * D^-1 * y_w for path updates
264        let mut bd_inv_yw = vec![0.0; n];
265        {
266            // First compute D^-1 * B^T * y_w
267            let mut temp = vec![0.0; n];
268            for i in 0..n {
269                for j in 0..n {
270                    temp[i] += self.eigenvectors[j][i] * y_w[j];
271                }
272                temp[i] /= self.eigenvalues[i].sqrt().max(1e-16);
273            }
274            // Then compute B * temp
275            for i in 0..n {
276                for j in 0..n {
277                    bd_inv_yw[i] += self.eigenvectors[i][j] * temp[j];
278                }
279            }
280        }
281
282        // Update evolution path for sigma (p_σ)
283        let c_sigma_factor = (self.c_sigma * (2.0 - self.c_sigma) * self.mu_eff).sqrt();
284        for i in 0..n {
285            self.path_sigma[i] =
286                (1.0 - self.c_sigma) * self.path_sigma[i] + c_sigma_factor * bd_inv_yw[i];
287        }
288
289        // Calculate ||p_σ||²
290        let path_sigma_norm_sq: f64 = self.path_sigma.iter().map(|x| x * x).sum();
291        let path_sigma_norm = path_sigma_norm_sq.sqrt();
292
293        // Heaviside function for stall detection
294        let h_sigma = if path_sigma_norm
295            / (1.0 - (1.0 - self.c_sigma).powi((2 * (self.generation + 1)) as i32)).sqrt()
296            / self.chi_n
297            < 1.4 + 2.0 / (n as f64 + 1.0)
298        {
299            1.0
300        } else {
301            0.0
302        };
303
304        // Update evolution path for C (p_c)
305        let c_c_factor = (self.c_c * (2.0 - self.c_c) * self.mu_eff).sqrt();
306        for i in 0..n {
307            self.path_c[i] = (1.0 - self.c_c) * self.path_c[i] + h_sigma * c_c_factor * y_w[i];
308        }
309
310        // Update covariance matrix
311        let delta_h = (1.0 - h_sigma) * self.c_c * (2.0 - self.c_c);
312
313        for i in 0..n {
314            for j in 0..=i {
315                // Decay
316                self.covariance[i][j] *= 1.0 - self.c_1 - self.c_mu + delta_h * self.c_1;
317
318                // Rank-1 update
319                self.covariance[i][j] += self.c_1 * self.path_c[i] * self.path_c[j];
320
321                // Rank-μ update
322                for k in 0..self.mu {
323                    let y_k: Vec<f64> = selected[k]
324                        .0
325                        .genes()
326                        .iter()
327                        .zip(self.mean.iter())
328                        .map(|(&x, &m)| (x - m) / self.sigma)
329                        .collect();
330                    self.covariance[i][j] += self.c_mu * self.weights[k] * y_k[i] * y_k[j];
331                }
332
333                // Symmetry
334                if i != j {
335                    self.covariance[j][i] = self.covariance[i][j];
336                }
337            }
338        }
339
340        // Update mean
341        for i in 0..n {
342            self.mean[i] += self.sigma * y_w[i];
343        }
344
345        // Update sigma (step-size control)
346        self.sigma *= ((self.c_sigma / self.d_sigma) * (path_sigma_norm / self.chi_n - 1.0)).exp();
347
348        // Update generation counter
349        self.generation += 1;
350
351        // Update eigendecomposition periodically
352        // (expensive, so don't do it every generation)
353        let update_eigendecomp = self.generation - self.eigen_eval
354            > (self.lambda as f64 / (self.c_1 + self.c_mu) / n as f64 / 10.0) as usize;
355
356        if update_eigendecomp {
357            self.update_eigensystem();
358            self.eigen_eval = self.generation;
359        }
360
361        // Track best solution
362        if let Some((best_genome, best_fit)) = offspring.first() {
363            if *best_fit < self.best_fitness {
364                self.best_fitness = *best_fit;
365                self.best_solution = best_genome.genes().to_vec();
366            }
367        }
368    }
369
370    /// Update eigendecomposition of C
371    fn update_eigensystem(&mut self) {
372        let n = self.dimension;
373
374        // Force symmetry
375        for i in 0..n {
376            for j in 0..i {
377                self.covariance[i][j] = (self.covariance[i][j] + self.covariance[j][i]) / 2.0;
378                self.covariance[j][i] = self.covariance[i][j];
379            }
380        }
381
382        // Simple Jacobi eigendecomposition
383        // In production, you'd use a linear algebra library
384        let (eigenvalues, eigenvectors) = jacobi_eigendecomposition(&self.covariance);
385
386        self.eigenvalues = eigenvalues;
387        self.eigenvectors = eigenvectors;
388
389        // Ensure eigenvalues are positive (numerical stability)
390        for ev in &mut self.eigenvalues {
391            *ev = ev.max(1e-16);
392        }
393    }
394
395    /// Check if algorithm has converged
396    pub fn has_converged(&self) -> bool {
397        // Check for various convergence criteria
398        let max_eigenvalue = self
399            .eigenvalues
400            .iter()
401            .cloned()
402            .fold(f64::NEG_INFINITY, f64::max);
403        let min_eigenvalue = self
404            .eigenvalues
405            .iter()
406            .cloned()
407            .fold(f64::INFINITY, f64::min);
408
409        // Condition number too large
410        if max_eigenvalue / min_eigenvalue.max(1e-16) > 1e14 {
411            return true;
412        }
413
414        // Sigma too small
415        if self.sigma < 1e-16 {
416            return true;
417        }
418
419        // Sigma times max standard deviation very small
420        if self.sigma * max_eigenvalue.sqrt() < 1e-16 {
421            return true;
422        }
423
424        false
425    }
426}
427
428/// Simple Jacobi eigendecomposition for symmetric matrices
429fn jacobi_eigendecomposition(a: &[Vec<f64>]) -> (Vec<f64>, Vec<Vec<f64>>) {
430    let n = a.len();
431    let mut d: Vec<f64> = (0..n).map(|i| a[i][i]).collect();
432    let mut v: Vec<Vec<f64>> = (0..n)
433        .map(|i| {
434            let mut row = vec![0.0; n];
435            row[i] = 1.0;
436            row
437        })
438        .collect();
439    let mut b = d.clone();
440    let mut z = vec![0.0; n];
441
442    let max_sweeps = 50;
443
444    for _ in 0..max_sweeps {
445        let mut sm = 0.0;
446        for p in 0..n - 1 {
447            for q in p + 1..n {
448                sm += a[p][q].abs();
449            }
450        }
451
452        if sm < 1e-16 {
453            break;
454        }
455
456        for p in 0..n - 1 {
457            for q in p + 1..n {
458                let h = d[q] - d[p];
459                let mut t: f64;
460
461                if a[p][q].abs() < 1e-100 {
462                    t = 0.0;
463                } else if h.abs() < 1e-100 {
464                    t = a[p][q].signum();
465                } else {
466                    let theta = 0.5 * h / a[p][q];
467                    t = 1.0 / (theta.abs() + (1.0 + theta * theta).sqrt());
468                    if theta < 0.0 {
469                        t = -t;
470                    }
471                }
472
473                let c = 1.0 / (1.0 + t * t).sqrt();
474                let s = t * c;
475                let tau = s / (1.0 + c);
476
477                let h = t * a[p][q];
478                z[p] -= h;
479                z[q] += h;
480                d[p] -= h;
481                d[q] += h;
482
483                // Rotate rows/columns
484                for j in 0..p {
485                    let g = a[j][p];
486                    let h = a[j][q];
487                    let apj = g - s * (h + g * tau);
488                    let aqj = h + s * (g - h * tau);
489                    // Note: we can't modify a, so we track rotations through v
490                    let _ = (apj, aqj);
491                }
492
493                // Update eigenvectors
494                for j in 0..n {
495                    let g = v[j][p];
496                    let h = v[j][q];
497                    v[j][p] = g - s * (h + g * tau);
498                    v[j][q] = h + s * (g - h * tau);
499                }
500            }
501        }
502
503        for i in 0..n {
504            b[i] += z[i];
505            d[i] = b[i];
506            z[i] = 0.0;
507        }
508    }
509
510    (d, v)
511}
512
513/// Implement CmaEsFitness for any Fn that matches the signature
514#[cfg(feature = "parallel")]
515impl<F> CmaEsFitness for F
516where
517    F: Fn(&RealVector) -> f64 + Send + Sync,
518{
519    fn evaluate(&self, x: &RealVector) -> f64 {
520        self(x)
521    }
522}
523
524/// Implement CmaEsFitness for any Fn that matches the signature
525#[cfg(not(feature = "parallel"))]
526impl<F> CmaEsFitness for F
527where
528    F: Fn(&RealVector) -> f64,
529{
530    fn evaluate(&self, x: &RealVector) -> f64 {
531        self(x)
532    }
533}
534
535/// CMA-ES optimizer
536#[derive(Clone)]
537pub struct CmaEs<F> {
538    /// State of the optimizer
539    pub state: CmaEsState,
540    /// Problem bounds
541    pub bounds: Option<MultiBounds>,
542    /// Fitness function marker
543    _phantom: PhantomData<F>,
544}
545
546impl<F: CmaEsFitness> CmaEs<F> {
547    /// Create a new CMA-ES optimizer
548    pub fn new(initial_mean: Vec<f64>, initial_sigma: f64) -> Self {
549        Self {
550            state: CmaEsState::new(initial_mean, initial_sigma, None),
551            bounds: None,
552            _phantom: PhantomData,
553        }
554    }
555
556    /// Create with custom population size
557    pub fn with_lambda(initial_mean: Vec<f64>, initial_sigma: f64, lambda: usize) -> Self {
558        Self {
559            state: CmaEsState::new(initial_mean, initial_sigma, Some(lambda)),
560            bounds: None,
561            _phantom: PhantomData,
562        }
563    }
564
565    /// Set problem bounds
566    pub fn with_bounds(mut self, bounds: MultiBounds) -> Self {
567        self.bounds = Some(bounds);
568        self
569    }
570
571    /// Run a single generation
572    pub fn step<R: Rng>(
573        &mut self,
574        fitness: &F,
575        rng: &mut R,
576    ) -> EvoResult<Vec<Individual<RealVector>>> {
577        // Sample offspring
578        let mut offspring = self.state.sample_population(rng);
579
580        // Apply bounds if present
581        if let Some(ref bounds) = self.bounds {
582            for genome in &mut offspring {
583                genome.apply_bounds(bounds);
584            }
585        }
586
587        // Evaluate fitness
588        let mut evaluated: Vec<(RealVector, f64)> = offspring
589            .into_iter()
590            .map(|g| {
591                let f = fitness.evaluate(&g);
592                (g, f)
593            })
594            .collect();
595
596        self.state.evaluations += evaluated.len();
597
598        // Sort by fitness (minimization)
599        evaluated.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
600
601        // Update state
602        self.state.update(&evaluated);
603
604        // Convert to individuals
605        let individuals: Vec<Individual<RealVector>> = evaluated
606            .into_iter()
607            .map(|(g, f)| Individual::with_fitness(g, f))
608            .collect();
609
610        Ok(individuals)
611    }
612
613    /// Run the optimizer for a fixed number of generations
614    pub fn run_generations<R: Rng>(
615        &mut self,
616        fitness: &F,
617        max_generations: usize,
618        rng: &mut R,
619    ) -> EvoResult<Individual<RealVector>> {
620        let mut best: Option<Individual<RealVector>> = None;
621
622        for _ in 0..max_generations {
623            let population = self.step(fitness, rng)?;
624
625            // Track best
626            if let Some(current_best) = population.first() {
627                match &best {
628                    None => best = Some(current_best.clone()),
629                    Some(existing) => {
630                        if current_best.fitness_f64() < existing.fitness_f64() {
631                            best = Some(current_best.clone());
632                        }
633                    }
634                }
635            }
636
637            // Check internal convergence
638            if self.state.has_converged() {
639                break;
640            }
641        }
642
643        best.ok_or(EvolutionError::EmptyPopulation)
644    }
645
646    /// Run the optimizer until a target fitness is reached or max generations
647    pub fn run_until<R: Rng>(
648        &mut self,
649        fitness: &F,
650        target_fitness: f64,
651        max_generations: usize,
652        rng: &mut R,
653    ) -> EvoResult<Individual<RealVector>> {
654        let mut best: Option<Individual<RealVector>> = None;
655
656        for _ in 0..max_generations {
657            let population = self.step(fitness, rng)?;
658
659            // Track best
660            if let Some(current_best) = population.first() {
661                match &best {
662                    None => best = Some(current_best.clone()),
663                    Some(existing) => {
664                        if current_best.fitness_f64() < existing.fitness_f64() {
665                            best = Some(current_best.clone());
666                        }
667                    }
668                }
669            }
670
671            // Check target fitness
672            if let Some(ref b) = best {
673                if b.fitness_f64() <= target_fitness {
674                    break;
675                }
676            }
677
678            // Check internal convergence
679            if self.state.has_converged() {
680                break;
681            }
682        }
683
684        best.ok_or(EvolutionError::EmptyPopulation)
685    }
686
687    /// Get the current generation
688    pub fn generation(&self) -> usize {
689        self.state.generation
690    }
691
692    /// Get total evaluations
693    pub fn evaluations(&self) -> usize {
694        self.state.evaluations
695    }
696
697    /// Get current mean
698    pub fn mean(&self) -> &[f64] {
699        &self.state.mean
700    }
701
702    /// Get current sigma
703    pub fn sigma(&self) -> f64 {
704        self.state.sigma
705    }
706
707    /// Get the best solution found
708    pub fn best_solution(&self) -> &[f64] {
709        &self.state.best_solution
710    }
711
712    /// Get the best fitness found
713    pub fn best_fitness(&self) -> f64 {
714        self.state.best_fitness
715    }
716}
717
718/// Builder for CMA-ES
719pub struct CmaEsBuilder {
720    initial_mean: Option<Vec<f64>>,
721    initial_sigma: f64,
722    lambda: Option<usize>,
723    bounds: Option<MultiBounds>,
724}
725
726impl CmaEsBuilder {
727    /// Create a new builder
728    pub fn new() -> Self {
729        Self {
730            initial_mean: None,
731            initial_sigma: 1.0,
732            lambda: None,
733            bounds: None,
734        }
735    }
736
737    /// Set the initial mean
738    pub fn mean(mut self, mean: Vec<f64>) -> Self {
739        self.initial_mean = Some(mean);
740        self
741    }
742
743    /// Set the initial step size (sigma)
744    pub fn sigma(mut self, sigma: f64) -> Self {
745        self.initial_sigma = sigma;
746        self
747    }
748
749    /// Set the population size
750    pub fn lambda(mut self, lambda: usize) -> Self {
751        self.lambda = Some(lambda);
752        self
753    }
754
755    /// Set the bounds
756    pub fn bounds(mut self, bounds: MultiBounds) -> Self {
757        self.bounds = Some(bounds);
758        self
759    }
760
761    /// Build the CMA-ES optimizer
762    pub fn build<F: CmaEsFitness>(self) -> EvoResult<CmaEs<F>> {
763        let mean = self
764            .initial_mean
765            .ok_or_else(|| EvolutionError::Configuration("Initial mean not set".to_string()))?;
766
767        let mut cmaes = match self.lambda {
768            Some(l) => CmaEs::with_lambda(mean, self.initial_sigma, l),
769            None => CmaEs::new(mean, self.initial_sigma),
770        };
771
772        if let Some(bounds) = self.bounds {
773            cmaes = cmaes.with_bounds(bounds);
774        }
775
776        Ok(cmaes)
777    }
778}
779
780impl Default for CmaEsBuilder {
781    fn default() -> Self {
782        Self::new()
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::genome::traits::EvolutionaryGenome;
790    use approx::assert_relative_eq;
791
792    // Simple sphere function for testing (minimization: lower is better)
793    struct Sphere;
794
795    impl CmaEsFitness for Sphere {
796        fn evaluate(&self, genome: &RealVector) -> f64 {
797            genome.genes().iter().map(|x| x * x).sum()
798        }
799    }
800
801    #[test]
802    fn test_cmaes_state_initialization() {
803        let mean = vec![0.0, 0.0, 0.0];
804        let state = CmaEsState::new(mean.clone(), 1.0, None);
805
806        assert_eq!(state.dimension, 3);
807        assert_eq!(state.mean, mean);
808        assert_eq!(state.sigma, 1.0);
809        assert!(state.lambda >= 4);
810        assert!(state.mu > 0);
811        assert_eq!(state.weights.len(), state.mu);
812    }
813
814    #[test]
815    fn test_cmaes_weights_sum_to_one() {
816        let state = CmaEsState::new(vec![0.0; 10], 1.0, None);
817        let sum: f64 = state.weights.iter().sum();
818        assert_relative_eq!(sum, 1.0, epsilon = 1e-10);
819    }
820
821    #[test]
822    fn test_cmaes_sampling() {
823        let mut rng = rand::thread_rng();
824        let state = CmaEsState::new(vec![0.0; 5], 1.0, Some(10));
825
826        let samples = state.sample_population(&mut rng);
827
828        assert_eq!(samples.len(), 10);
829        for sample in &samples {
830            assert_eq!(sample.dimension(), 5);
831        }
832    }
833
834    #[test]
835    fn test_cmaes_step() {
836        let mut rng = rand::thread_rng();
837        let fitness = Sphere;
838        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![5.0, 5.0, 5.0], 2.0);
839
840        let population = cmaes.step(&fitness, &mut rng).unwrap();
841
842        assert_eq!(population.len(), cmaes.state.lambda);
843        assert_eq!(cmaes.state.generation, 1);
844    }
845
846    #[test]
847    fn test_cmaes_optimization() {
848        use rand::SeedableRng;
849        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
850        let fitness = Sphere;
851        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![5.0, 5.0], 2.0);
852
853        // Run for more generations to allow convergence
854        let result = cmaes.run_generations(&fitness, 150, &mut rng).unwrap();
855
856        // CMA-ES should find solution close to origin
857        // Starting from [5,5] (fitness=50), should improve significantly
858        let final_fitness = result.fitness_f64();
859        let initial_fitness = 50.0; // 5^2 + 5^2
860        assert!(
861            final_fitness < initial_fitness * 0.7,
862            "Final fitness {} should be significantly better than initial {}",
863            final_fitness,
864            initial_fitness
865        );
866    }
867
868    #[test]
869    fn test_cmaes_with_bounds() {
870        let mut rng = rand::thread_rng();
871        let fitness = Sphere;
872        let bounds = MultiBounds::symmetric(10.0, 3);
873
874        let mut cmaes: CmaEs<Sphere> = CmaEs::new(vec![5.0, 5.0, 5.0], 2.0).with_bounds(bounds);
875
876        let result = cmaes.run_generations(&fitness, 30, &mut rng).unwrap();
877
878        // Check all genes are within bounds
879        for gene in result.genome().genes() {
880            assert!(*gene >= -10.0 && *gene <= 10.0);
881        }
882    }
883
884    #[test]
885    fn test_cmaes_builder() {
886        let cmaes: CmaEs<Sphere> = CmaEsBuilder::new()
887            .mean(vec![0.0, 0.0, 0.0])
888            .sigma(0.5)
889            .lambda(20)
890            .bounds(MultiBounds::symmetric(5.0, 3))
891            .build()
892            .unwrap();
893
894        assert_eq!(cmaes.state.lambda, 20);
895        assert_eq!(cmaes.state.sigma, 0.5);
896        assert!(cmaes.bounds.is_some());
897    }
898
899    #[test]
900    fn test_cmaes_convergence_detection() {
901        let mut state = CmaEsState::new(vec![0.0, 0.0], 1e-20, None);
902        assert!(state.has_converged());
903
904        state.sigma = 1.0;
905        state.eigenvalues = vec![1e15, 1.0];
906        assert!(state.has_converged());
907    }
908
909    #[test]
910    fn test_jacobi_eigendecomposition() {
911        // Test with a known symmetric matrix
912        let a = vec![vec![4.0, 1.0], vec![1.0, 3.0]];
913
914        let (eigenvalues, _eigenvectors) = jacobi_eigendecomposition(&a);
915
916        // Eigenvalues of [[4,1],[1,3]] are (7+sqrt(5))/2 ≈ 4.618 and (7-sqrt(5))/2 ≈ 2.382
917        let expected_sum = 7.0; // trace
918        let actual_sum: f64 = eigenvalues.iter().sum();
919        assert_relative_eq!(actual_sum, expected_sum, epsilon = 1e-6);
920    }
921}