Skip to main content

fugue_evo/algorithms/
evolution_strategy.rs

1//! Evolution Strategies: (μ+λ)-ES and (μ,λ)-ES
2//!
3//! This module implements the classic Evolution Strategy algorithms:
4//! - (μ+λ)-ES: Parents compete with offspring for survival
5//! - (μ,λ)-ES: Only offspring compete, parents are discarded
6//!
7//! Both support self-adaptive mutation through the `AdaptiveGenome` wrapper.
8
9use std::time::Instant;
10
11use rand::Rng;
12use rand_distr::StandardNormal;
13
14use crate::diagnostics::{EvolutionResult, EvolutionStats, GenerationStats, TimingStats};
15use crate::error::EvolutionError;
16use crate::fitness::traits::{Fitness, FitnessValue};
17use crate::genome::bounds::MultiBounds;
18use crate::genome::traits::{EvolutionaryGenome, RealValuedGenome};
19use crate::hyperparameter::self_adaptive::{AdaptiveGenome, StrategyParams};
20use crate::population::individual::Individual;
21use crate::population::population::Population;
22use crate::termination::{EvolutionState, MaxGenerations, TerminationCriterion};
23
24/// Selection strategy for Evolution Strategies
25///
26/// # Interaction with σ self-adaptation
27///
28/// Classical ES theory (Schwefel; Beyer & Schwefel 2002) shows that mutative
29/// step-size self-adaptation requires **comma** (`MuCommaLambda`) selection.
30/// Under **plus**/elitist selection an individual that happens to carry an
31/// over-small σ can survive indefinitely on the merit of its object variables,
32/// so badly-scaled strategy parameters are never purged and σ tends to collapse,
33/// stalling adaptation. The canonical self-adaptive recommendation is `(μ,λ)`
34/// with `λ/μ ≈ 7` (e.g. `(15, 100)`). Consequently the default configuration
35/// (see [`ESConfig::default`]) pairs self-adaptation with comma selection; plus
36/// selection remains available as an explicit opt-in.
37#[derive(Clone, Debug, Default)]
38pub enum ESSelectionStrategy {
39    /// (μ+λ): Select best μ from parents + offspring combined.
40    ///
41    /// Elitist. Prefer this only when self-adaptation is disabled or when
42    /// elitism is explicitly desired — see the type-level note on why elitism
43    /// suppresses mutative σ self-adaptation.
44    MuPlusLambda,
45    /// (μ,λ): Select best μ from offspring only (requires λ ≥ μ).
46    ///
47    /// The correct pairing for mutative σ self-adaptation, and the default.
48    #[default]
49    MuCommaLambda,
50}
51
52/// Configuration for Evolution Strategies
53#[derive(Clone, Debug)]
54pub struct ESConfig {
55    /// Number of parents (μ)
56    pub mu: usize,
57    /// Number of offspring (λ)
58    pub lambda: usize,
59    /// Selection strategy
60    pub selection: ESSelectionStrategy,
61    /// Initial step size (σ)
62    pub initial_sigma: f64,
63    /// Use self-adaptive mutation (evolve σ)
64    pub self_adaptive: bool,
65    /// Recombination type
66    pub recombination: RecombinationType,
67    /// Problem-scaled lower bound on self-adaptive step sizes.
68    ///
69    /// `None` resolves to `1e-8 * initial_sigma` (see
70    /// [`ESConfig::resolved_min_sigma`]), which guards against premature
71    /// step-size collapse. This is distinct from the absolute underflow floor
72    /// baked into [`StrategyParams`], which only prevents σ reaching literal
73    /// zero.
74    pub min_sigma: Option<f64>,
75}
76
77impl Default for ESConfig {
78    /// Default configuration: `(15, 100)`-ES with mutative σ self-adaptation.
79    ///
80    /// Selection defaults to `MuCommaLambda` (comma) because self-adaptation is
81    /// enabled by default and elitist (plus) selection suppresses mutative
82    /// step-size adaptation — see [`ESSelectionStrategy`] for the theory.
83    fn default() -> Self {
84        Self {
85            mu: 15,
86            lambda: 100,
87            selection: ESSelectionStrategy::MuCommaLambda,
88            initial_sigma: 1.0,
89            self_adaptive: true,
90            recombination: RecombinationType::Intermediate,
91            min_sigma: None,
92        }
93    }
94}
95
96impl ESConfig {
97    /// Resolve the effective lower bound on self-adaptive step sizes.
98    ///
99    /// Uses the explicitly configured [`min_sigma`](Self::min_sigma) when set,
100    /// otherwise a problem-scaled default of `1e-8 * initial_sigma`.
101    pub fn resolved_min_sigma(&self) -> f64 {
102        self.min_sigma.unwrap_or(1e-8 * self.initial_sigma)
103    }
104
105    /// Create a (μ+λ)-ES configuration
106    pub fn mu_plus_lambda(mu: usize, lambda: usize) -> Self {
107        Self {
108            mu,
109            lambda,
110            selection: ESSelectionStrategy::MuPlusLambda,
111            ..Default::default()
112        }
113    }
114
115    /// Create a (μ,λ)-ES configuration
116    pub fn mu_comma_lambda(mu: usize, lambda: usize) -> Result<Self, EvolutionError> {
117        if lambda < mu {
118            return Err(EvolutionError::Configuration(format!(
119                "For (μ,λ)-ES, λ ({}) must be >= μ ({})",
120                lambda, mu
121            )));
122        }
123        Ok(Self {
124            mu,
125            lambda,
126            selection: ESSelectionStrategy::MuCommaLambda,
127            ..Default::default()
128        })
129    }
130}
131
132/// Recombination type for ES
133#[derive(Clone, Debug, Default)]
134pub enum RecombinationType {
135    /// No recombination (asexual)
136    None,
137    /// Discrete recombination (randomly select genes from parents)
138    Discrete,
139    /// Intermediate recombination (average of parents)
140    #[default]
141    Intermediate,
142    /// Global intermediate (average of all parents)
143    GlobalIntermediate,
144}
145
146/// Builder for Evolution Strategy
147pub struct ESBuilder<G, F, Fit, Term>
148where
149    G: EvolutionaryGenome,
150    F: FitnessValue,
151{
152    config: ESConfig,
153    bounds: Option<MultiBounds>,
154    fitness: Option<Fit>,
155    termination: Option<Term>,
156    _phantom: std::marker::PhantomData<(G, F)>,
157}
158
159impl<G, F> ESBuilder<G, F, (), ()>
160where
161    G: EvolutionaryGenome,
162    F: FitnessValue,
163{
164    /// Create a new builder with default configuration
165    pub fn new() -> Self {
166        Self {
167            config: ESConfig::default(),
168            bounds: None,
169            fitness: None,
170            termination: None,
171            _phantom: std::marker::PhantomData,
172        }
173    }
174
175    /// Create a (μ+λ)-ES builder
176    pub fn mu_plus_lambda(mu: usize, lambda: usize) -> Self {
177        Self {
178            config: ESConfig::mu_plus_lambda(mu, lambda),
179            bounds: None,
180            fitness: None,
181            termination: None,
182            _phantom: std::marker::PhantomData,
183        }
184    }
185
186    /// Create a (μ,λ)-ES builder
187    pub fn mu_comma_lambda(mu: usize, lambda: usize) -> Result<Self, EvolutionError> {
188        Ok(Self {
189            config: ESConfig::mu_comma_lambda(mu, lambda)?,
190            bounds: None,
191            fitness: None,
192            termination: None,
193            _phantom: std::marker::PhantomData,
194        })
195    }
196}
197
198impl<G, F> Default for ESBuilder<G, F, (), ()>
199where
200    G: EvolutionaryGenome,
201    F: FitnessValue,
202{
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208impl<G, F, Fit, Term> ESBuilder<G, F, Fit, Term>
209where
210    G: EvolutionaryGenome,
211    F: FitnessValue,
212{
213    /// Set μ (number of parents)
214    pub fn mu(mut self, mu: usize) -> Self {
215        self.config.mu = mu;
216        self
217    }
218
219    /// Set λ (number of offspring)
220    pub fn lambda(mut self, lambda: usize) -> Self {
221        self.config.lambda = lambda;
222        self
223    }
224
225    /// Set the selection strategy
226    pub fn selection_strategy(mut self, strategy: ESSelectionStrategy) -> Self {
227        self.config.selection = strategy;
228        self
229    }
230
231    /// Set the initial step size
232    pub fn initial_sigma(mut self, sigma: f64) -> Self {
233        self.config.initial_sigma = sigma;
234        self
235    }
236
237    /// Set a problem-scaled lower bound on self-adaptive step sizes.
238    ///
239    /// Guards against premature step-size collapse. When unset, defaults to
240    /// `1e-8 * initial_sigma`.
241    pub fn min_sigma(mut self, sigma: f64) -> Self {
242        self.config.min_sigma = Some(sigma);
243        self
244    }
245
246    /// Enable or disable self-adaptive mutation
247    pub fn self_adaptive(mut self, enabled: bool) -> Self {
248        self.config.self_adaptive = enabled;
249        self
250    }
251
252    /// Set the recombination type
253    pub fn recombination(mut self, recomb: RecombinationType) -> Self {
254        self.config.recombination = recomb;
255        self
256    }
257
258    /// Set the search space bounds
259    pub fn bounds(mut self, bounds: MultiBounds) -> Self {
260        self.bounds = Some(bounds);
261        self
262    }
263
264    /// Set the fitness function
265    pub fn fitness<NewFit>(self, fitness: NewFit) -> ESBuilder<G, F, NewFit, Term>
266    where
267        NewFit: Fitness<Genome = G, Value = F>,
268    {
269        ESBuilder {
270            config: self.config,
271            bounds: self.bounds,
272            fitness: Some(fitness),
273            termination: self.termination,
274            _phantom: std::marker::PhantomData,
275        }
276    }
277
278    /// Set the termination criterion
279    pub fn termination<NewTerm>(self, termination: NewTerm) -> ESBuilder<G, F, Fit, NewTerm>
280    where
281        NewTerm: TerminationCriterion<G, F>,
282    {
283        ESBuilder {
284            config: self.config,
285            bounds: self.bounds,
286            fitness: self.fitness,
287            termination: Some(termination),
288            _phantom: std::marker::PhantomData,
289        }
290    }
291
292    /// Set max generations (convenience method)
293    pub fn max_generations(self, max: usize) -> ESBuilder<G, F, Fit, MaxGenerations> {
294        ESBuilder {
295            config: self.config,
296            bounds: self.bounds,
297            fitness: self.fitness,
298            termination: Some(MaxGenerations::new(max)),
299            _phantom: std::marker::PhantomData,
300        }
301    }
302}
303
304// Parallel version with Send + Sync bounds
305#[cfg(feature = "parallel")]
306impl<G, F, Fit, Term> ESBuilder<G, F, Fit, Term>
307where
308    G: EvolutionaryGenome + RealValuedGenome + Send + Sync,
309    F: FitnessValue + Send,
310    Fit: Fitness<Genome = G, Value = F> + Sync,
311    Term: TerminationCriterion<G, F>,
312{
313    /// Build the Evolution Strategy instance
314    pub fn build(self) -> Result<EvolutionStrategy<G, F, Fit, Term>, EvolutionError> {
315        let bounds = self
316            .bounds
317            .ok_or_else(|| EvolutionError::Configuration("Bounds must be specified".to_string()))?;
318
319        let fitness = self.fitness.ok_or_else(|| {
320            EvolutionError::Configuration("Fitness function must be specified".to_string())
321        })?;
322
323        let termination = self.termination.ok_or_else(|| {
324            EvolutionError::Configuration("Termination criterion must be specified".to_string())
325        })?;
326
327        // Validate (μ,λ) constraint
328        if matches!(self.config.selection, ESSelectionStrategy::MuCommaLambda)
329            && self.config.lambda < self.config.mu
330        {
331            return Err(EvolutionError::Configuration(format!(
332                "For (μ,λ)-ES, λ ({}) must be >= μ ({})",
333                self.config.lambda, self.config.mu
334            )));
335        }
336
337        Ok(EvolutionStrategy {
338            config: self.config,
339            bounds,
340            fitness,
341            termination,
342            _phantom: std::marker::PhantomData,
343        })
344    }
345}
346
347// Non-parallel version of build()
348#[cfg(not(feature = "parallel"))]
349impl<G, F, Fit, Term> ESBuilder<G, F, Fit, Term>
350where
351    G: EvolutionaryGenome + RealValuedGenome,
352    F: FitnessValue,
353    Fit: Fitness<Genome = G, Value = F>,
354    Term: TerminationCriterion<G, F>,
355{
356    /// Build the Evolution Strategy instance
357    pub fn build(self) -> Result<EvolutionStrategy<G, F, Fit, Term>, EvolutionError> {
358        let bounds = self
359            .bounds
360            .ok_or_else(|| EvolutionError::Configuration("Bounds must be specified".to_string()))?;
361
362        let fitness = self.fitness.ok_or_else(|| {
363            EvolutionError::Configuration("Fitness function must be specified".to_string())
364        })?;
365
366        let termination = self.termination.ok_or_else(|| {
367            EvolutionError::Configuration("Termination criterion must be specified".to_string())
368        })?;
369
370        // Validate (μ,λ) constraint
371        if matches!(self.config.selection, ESSelectionStrategy::MuCommaLambda)
372            && self.config.lambda < self.config.mu
373        {
374            return Err(EvolutionError::Configuration(format!(
375                "For (μ,λ)-ES, λ ({}) must be >= μ ({})",
376                self.config.lambda, self.config.mu
377            )));
378        }
379
380        Ok(EvolutionStrategy {
381            config: self.config,
382            bounds,
383            fitness,
384            termination,
385            _phantom: std::marker::PhantomData,
386        })
387    }
388}
389
390/// Evolution Strategy (μ+λ)-ES or (μ,λ)-ES
391///
392/// A classic evolutionary algorithm using Gaussian mutation and optional
393/// self-adaptive step size control.
394pub struct EvolutionStrategy<G, F, Fit, Term>
395where
396    G: EvolutionaryGenome,
397    F: FitnessValue,
398{
399    config: ESConfig,
400    bounds: MultiBounds,
401    fitness: Fit,
402    termination: Term,
403    _phantom: std::marker::PhantomData<(G, F)>,
404}
405
406// Parallel version with Send + Sync bounds
407#[cfg(feature = "parallel")]
408impl<G, F, Fit, Term> EvolutionStrategy<G, F, Fit, Term>
409where
410    G: EvolutionaryGenome + RealValuedGenome + Send + Sync,
411    F: FitnessValue + Send,
412    Fit: Fitness<Genome = G, Value = F> + Sync,
413    Term: TerminationCriterion<G, F>,
414{
415    /// Create a builder for Evolution Strategy
416    pub fn builder() -> ESBuilder<G, F, (), ()> {
417        ESBuilder::new()
418    }
419
420    /// Run the evolution strategy.
421    pub fn run<R: Rng>(&self, rng: &mut R) -> Result<EvolutionResult<G, F>, EvolutionError> {
422        // Delegate to the callback-driven loop with a no-op observer, so `run`
423        // and `run_with_callback` share exactly one loop body.
424        self.run_with_callback(rng, |_generation, _best_fitness| true)
425    }
426
427    /// Run the evolution strategy, invoking `on_generation(generation,
428    /// best_fitness)` once per generation before that generation is evolved.
429    ///
430    /// Returning `false` from the callback cancels the run early and returns the
431    /// best-so-far result (AUDIT EV-34: lets the WASM layer report per-generation
432    /// progress and support cancellation without a separate step API).
433    pub fn run_with_callback<R: Rng, Cb: FnMut(usize, f64) -> bool>(
434        &self,
435        rng: &mut R,
436        mut on_generation: Cb,
437    ) -> Result<EvolutionResult<G, F>, EvolutionError> {
438        let start_time = Instant::now();
439
440        // Initialize population with adaptive genomes
441        let mut population: Vec<(AdaptiveGenome<G>, F)> = (0..self.config.mu)
442            .map(|_| {
443                let genome = G::generate(rng, &self.bounds);
444                let adaptive = if self.config.self_adaptive {
445                    AdaptiveGenome::new_non_isotropic(
446                        genome,
447                        vec![self.config.initial_sigma; self.bounds.dimension()],
448                    )
449                } else {
450                    AdaptiveGenome::new_isotropic(genome, self.config.initial_sigma)
451                };
452                let fitness = self.fitness.evaluate(adaptive.inner());
453                (adaptive, fitness)
454            })
455            .collect();
456
457        // Sort by fitness (descending for maximization)
458        population.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
459
460        let mut stats = EvolutionStats::new();
461        let mut evaluations = self.config.mu;
462        let mut fitness_history: Vec<f64> = Vec::new();
463        let mut generation = 0usize;
464
465        // Track best individual
466        let mut best = population[0].clone();
467
468        // Create a tracking population for termination checks
469        let mut tracking_population: Population<G, F> = Population::with_capacity(self.config.mu);
470        for (adaptive, fit) in &population {
471            let mut ind = Individual::new(adaptive.inner().clone());
472            ind.set_fitness(fit.clone());
473            tracking_population.push(ind);
474        }
475
476        // Record initial statistics
477        let gen_stats = GenerationStats::from_population(&tracking_population, 0, evaluations);
478        fitness_history.push(gen_stats.best_fitness);
479        stats.record(gen_stats);
480
481        // Main evolution loop
482        loop {
483            // Check termination
484            let state = EvolutionState {
485                generation,
486                evaluations,
487                best_fitness: best.1.to_f64(),
488                population: &tracking_population,
489                fitness_history: &fitness_history,
490            };
491
492            if self.termination.should_terminate(&state) {
493                stats.set_termination_reason(self.termination.reason());
494                break;
495            }
496
497            // EV-34: per-generation progress/cancel hook. A `false` return cancels
498            // the run, returning the best individual found so far.
499            if !on_generation(generation, best.1.to_f64()) {
500                break;
501            }
502
503            let gen_start = Instant::now();
504
505            // Generate offspring
506            let mut offspring: Vec<(AdaptiveGenome<G>, F)> = Vec::with_capacity(self.config.lambda);
507
508            for _ in 0..self.config.lambda {
509                // Select parent(s) for recombination
510                let child = match &self.config.recombination {
511                    RecombinationType::None => {
512                        // Clone a random parent
513                        let parent_idx = rng.gen_range(0..self.config.mu);
514                        population[parent_idx].0.clone()
515                    }
516                    RecombinationType::Discrete => {
517                        // Select two parents, randomly pick genes
518                        let p1_idx = rng.gen_range(0..self.config.mu);
519                        let p2_idx = rng.gen_range(0..self.config.mu);
520                        self.discrete_recombination(
521                            &population[p1_idx].0,
522                            &population[p2_idx].0,
523                            rng,
524                        )
525                    }
526                    RecombinationType::Intermediate => {
527                        // Average of two parents
528                        let p1_idx = rng.gen_range(0..self.config.mu);
529                        let p2_idx = rng.gen_range(0..self.config.mu);
530                        self.intermediate_recombination(
531                            &population[p1_idx].0,
532                            &population[p2_idx].0,
533                        )
534                    }
535                    RecombinationType::GlobalIntermediate => {
536                        // Average of all parents
537                        self.global_intermediate_recombination(&population)
538                    }
539                };
540
541                // Mutate
542                let mutated = self.mutate(child, rng);
543
544                // Evaluate
545                let fitness = self.fitness.evaluate(mutated.inner());
546                offspring.push((mutated, fitness));
547            }
548            evaluations += self.config.lambda;
549
550            // Selection
551            match self.config.selection {
552                ESSelectionStrategy::MuPlusLambda => {
553                    // Combine parents and offspring
554                    let mut combined = population;
555                    combined.extend(offspring);
556                    combined
557                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
558                    population = combined.into_iter().take(self.config.mu).collect();
559                }
560                ESSelectionStrategy::MuCommaLambda => {
561                    // Select only from offspring
562                    offspring
563                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
564                    population = offspring.into_iter().take(self.config.mu).collect();
565                }
566            }
567
568            // Update best
569            if population[0].1.is_better_than(&best.1) {
570                best = population[0].clone();
571            }
572
573            generation += 1;
574
575            // Update tracking population for statistics
576            tracking_population.clear();
577            for (adaptive, fit) in &population {
578                let mut ind = Individual::new(adaptive.inner().clone());
579                ind.set_fitness(fit.clone());
580                tracking_population.push(ind);
581            }
582            tracking_population.set_generation(generation);
583
584            // Record statistics
585            let timing = TimingStats::new().with_total(gen_start.elapsed());
586            let gen_stats =
587                GenerationStats::from_population(&tracking_population, generation, evaluations)
588                    .with_timing(timing);
589            fitness_history.push(gen_stats.best_fitness);
590            stats.record(gen_stats);
591        }
592
593        stats.set_runtime(start_time.elapsed());
594
595        Ok(
596            EvolutionResult::new(best.0.into_inner(), best.1, generation, evaluations)
597                .with_stats(stats),
598        )
599    }
600
601    /// Discrete recombination: randomly select genes from parents
602    fn discrete_recombination<R: Rng>(
603        &self,
604        p1: &AdaptiveGenome<G>,
605        p2: &AdaptiveGenome<G>,
606        rng: &mut R,
607    ) -> AdaptiveGenome<G> {
608        let genes1 = p1.inner().genes();
609        let genes2 = p2.inner().genes();
610
611        let child_genes: Vec<f64> = genes1
612            .iter()
613            .zip(genes2.iter())
614            .map(|(g1, g2)| if rng.gen_bool(0.5) { *g1 } else { *g2 })
615            .collect();
616
617        let child_genome = G::from_genes(child_genes).expect("Failed to create genome from genes");
618
619        // Recombine strategy parameters
620        match (&p1.strategy, &p2.strategy) {
621            (StrategyParams::Isotropic(s1), StrategyParams::Isotropic(s2)) => {
622                AdaptiveGenome::new_isotropic(child_genome, (s1 * s2).sqrt())
623            }
624            (StrategyParams::NonIsotropic(s1), StrategyParams::NonIsotropic(s2)) => {
625                let sigmas: Vec<f64> = s1
626                    .iter()
627                    .zip(s2.iter())
628                    .map(|(a, b)| if rng.gen_bool(0.5) { *a } else { *b })
629                    .collect();
630                AdaptiveGenome::new_non_isotropic(child_genome, sigmas)
631            }
632            _ => AdaptiveGenome::new_isotropic(child_genome, self.config.initial_sigma),
633        }
634    }
635
636    /// Intermediate recombination: average of two parents
637    fn intermediate_recombination(
638        &self,
639        p1: &AdaptiveGenome<G>,
640        p2: &AdaptiveGenome<G>,
641    ) -> AdaptiveGenome<G> {
642        let genes1 = p1.inner().genes();
643        let genes2 = p2.inner().genes();
644
645        let child_genes: Vec<f64> = genes1
646            .iter()
647            .zip(genes2.iter())
648            .map(|(g1, g2)| (g1 + g2) / 2.0)
649            .collect();
650
651        let child_genome = G::from_genes(child_genes).expect("Failed to create genome from genes");
652
653        // Average strategy parameters
654        match (&p1.strategy, &p2.strategy) {
655            (StrategyParams::Isotropic(s1), StrategyParams::Isotropic(s2)) => {
656                AdaptiveGenome::new_isotropic(child_genome, (s1 * s2).sqrt())
657            }
658            (StrategyParams::NonIsotropic(s1), StrategyParams::NonIsotropic(s2)) => {
659                let sigmas: Vec<f64> = s1
660                    .iter()
661                    .zip(s2.iter())
662                    .map(|(a, b)| (a * b).sqrt())
663                    .collect();
664                AdaptiveGenome::new_non_isotropic(child_genome, sigmas)
665            }
666            _ => AdaptiveGenome::new_isotropic(child_genome, self.config.initial_sigma),
667        }
668    }
669
670    /// Global intermediate recombination: average of all parents
671    fn global_intermediate_recombination(
672        &self,
673        population: &[(AdaptiveGenome<G>, F)],
674    ) -> AdaptiveGenome<G> {
675        let n = population.len();
676        let dim = population[0].0.inner().genes().len();
677
678        let mut child_genes = vec![0.0; dim];
679        for (adaptive, _) in population {
680            for (i, gene) in adaptive.inner().genes().iter().enumerate() {
681                child_genes[i] += gene / n as f64;
682            }
683        }
684
685        let child_genome = G::from_genes(child_genes).expect("Failed to create genome from genes");
686
687        // Average all strategy parameters
688        let avg_sigma = if self.config.self_adaptive {
689            // Average the sigmas from all parents
690            let sigmas: Vec<f64> = (0..dim)
691                .map(|i| {
692                    let sum: f64 = population
693                        .iter()
694                        .map(|(a, _)| a.strategy.get_sigma(i))
695                        .sum();
696                    sum / n as f64
697                })
698                .collect();
699            AdaptiveGenome::new_non_isotropic(child_genome, sigmas)
700        } else {
701            AdaptiveGenome::new_isotropic(child_genome, self.config.initial_sigma)
702        };
703
704        avg_sigma
705    }
706
707    /// Mutate an adaptive genome
708    fn mutate<R: Rng>(&self, mut genome: AdaptiveGenome<G>, rng: &mut R) -> AdaptiveGenome<G> {
709        let n = genome.inner().genes().len();
710
711        // Self-adaptive: mutate strategy parameters first
712        if self.config.self_adaptive {
713            genome
714                .strategy
715                .mutate(n, self.config.resolved_min_sigma(), rng);
716        }
717
718        // Collect sigmas first to avoid borrow conflicts
719        let sigmas: Vec<f64> = (0..n).map(|i| genome.strategy.get_sigma(i)).collect();
720
721        // Then mutate the genome using the (possibly updated) strategy
722        let genes = genome.inner_mut().genes_mut();
723        for i in 0..genes.len() {
724            let perturbation: f64 = rng.sample(StandardNormal);
725            genes[i] += sigmas[i] * perturbation;
726
727            // Clamp to bounds
728            if let Some(b) = self.bounds.get(i) {
729                genes[i] = genes[i].clamp(b.min, b.max);
730            }
731        }
732
733        genome
734    }
735}
736
737// Non-parallel version without Send + Sync bounds
738#[cfg(not(feature = "parallel"))]
739impl<G, F, Fit, Term> EvolutionStrategy<G, F, Fit, Term>
740where
741    G: EvolutionaryGenome + RealValuedGenome,
742    F: FitnessValue,
743    Fit: Fitness<Genome = G, Value = F>,
744    Term: TerminationCriterion<G, F>,
745{
746    /// Create a builder for Evolution Strategy
747    pub fn builder() -> ESBuilder<G, F, (), ()> {
748        ESBuilder::new()
749    }
750
751    /// Run the evolution strategy.
752    pub fn run<R: Rng>(&self, rng: &mut R) -> Result<EvolutionResult<G, F>, EvolutionError> {
753        // Delegate to the callback-driven loop with a no-op observer, so `run`
754        // and `run_with_callback` share exactly one loop body.
755        self.run_with_callback(rng, |_generation, _best_fitness| true)
756    }
757
758    /// Run the evolution strategy, invoking `on_generation(generation,
759    /// best_fitness)` once per generation before that generation is evolved.
760    ///
761    /// Returning `false` from the callback cancels the run early and returns the
762    /// best-so-far result (AUDIT EV-34: lets the WASM layer report per-generation
763    /// progress and support cancellation without a separate step API).
764    pub fn run_with_callback<R: Rng, Cb: FnMut(usize, f64) -> bool>(
765        &self,
766        rng: &mut R,
767        mut on_generation: Cb,
768    ) -> Result<EvolutionResult<G, F>, EvolutionError> {
769        let start_time = Instant::now();
770
771        // Initialize population with adaptive genomes
772        let mut population: Vec<(AdaptiveGenome<G>, F)> = (0..self.config.mu)
773            .map(|_| {
774                let genome = G::generate(rng, &self.bounds);
775                let adaptive = if self.config.self_adaptive {
776                    AdaptiveGenome::new_non_isotropic(
777                        genome,
778                        vec![self.config.initial_sigma; self.bounds.dimension()],
779                    )
780                } else {
781                    AdaptiveGenome::new_isotropic(genome, self.config.initial_sigma)
782                };
783                let fitness = self.fitness.evaluate(adaptive.inner());
784                (adaptive, fitness)
785            })
786            .collect();
787
788        // Sort by fitness (descending for maximization)
789        population.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
790
791        let mut stats = EvolutionStats::new();
792        let mut evaluations = self.config.mu;
793        let mut fitness_history: Vec<f64> = Vec::new();
794        let mut generation = 0usize;
795
796        // Track best individual
797        let mut best = population[0].clone();
798
799        // Create a tracking population for termination checks
800        let mut tracking_population: Population<G, F> = Population::with_capacity(self.config.mu);
801        for (adaptive, fit) in &population {
802            let mut ind = Individual::new(adaptive.inner().clone());
803            ind.set_fitness(fit.clone());
804            tracking_population.push(ind);
805        }
806
807        // Record initial statistics
808        let gen_stats = GenerationStats::from_population(&tracking_population, 0, evaluations);
809        fitness_history.push(gen_stats.best_fitness);
810        stats.record(gen_stats);
811
812        // Main evolution loop
813        loop {
814            // Check termination
815            let state = EvolutionState {
816                generation,
817                evaluations,
818                best_fitness: best.1.to_f64(),
819                population: &tracking_population,
820                fitness_history: &fitness_history,
821            };
822
823            if self.termination.should_terminate(&state) {
824                stats.set_termination_reason(self.termination.reason());
825                break;
826            }
827
828            // EV-34: per-generation progress/cancel hook. A `false` return cancels
829            // the run, returning the best individual found so far.
830            if !on_generation(generation, best.1.to_f64()) {
831                break;
832            }
833
834            let gen_start = Instant::now();
835
836            // Generate offspring
837            let mut offspring: Vec<(AdaptiveGenome<G>, F)> = Vec::with_capacity(self.config.lambda);
838
839            for _ in 0..self.config.lambda {
840                // Select parent(s) for recombination
841                let child = match &self.config.recombination {
842                    RecombinationType::None => {
843                        // Clone a random parent
844                        let parent_idx = rng.gen_range(0..self.config.mu);
845                        population[parent_idx].0.clone()
846                    }
847                    RecombinationType::Discrete => {
848                        // Select two parents, randomly pick genes
849                        let p1_idx = rng.gen_range(0..self.config.mu);
850                        let p2_idx = rng.gen_range(0..self.config.mu);
851                        self.discrete_recombination(
852                            &population[p1_idx].0,
853                            &population[p2_idx].0,
854                            rng,
855                        )
856                    }
857                    RecombinationType::Intermediate => {
858                        // Average of two parents
859                        let p1_idx = rng.gen_range(0..self.config.mu);
860                        let p2_idx = rng.gen_range(0..self.config.mu);
861                        self.intermediate_recombination(
862                            &population[p1_idx].0,
863                            &population[p2_idx].0,
864                        )
865                    }
866                    RecombinationType::GlobalIntermediate => {
867                        // Average of all parents
868                        self.global_intermediate_recombination(&population)
869                    }
870                };
871
872                // Mutate
873                let mutated = self.mutate(child, rng);
874
875                // Evaluate
876                let fitness = self.fitness.evaluate(mutated.inner());
877                offspring.push((mutated, fitness));
878            }
879            evaluations += self.config.lambda;
880
881            // Selection
882            match self.config.selection {
883                ESSelectionStrategy::MuPlusLambda => {
884                    // Combine parents and offspring
885                    let mut combined = population;
886                    combined.extend(offspring);
887                    combined
888                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
889                    population = combined.into_iter().take(self.config.mu).collect();
890                }
891                ESSelectionStrategy::MuCommaLambda => {
892                    // Select only from offspring
893                    offspring
894                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
895                    population = offspring.into_iter().take(self.config.mu).collect();
896                }
897            }
898
899            // Update best
900            if population[0].1.is_better_than(&best.1) {
901                best = population[0].clone();
902            }
903
904            generation += 1;
905
906            // Update tracking population for statistics
907            tracking_population.clear();
908            for (adaptive, fit) in &population {
909                let mut ind = Individual::new(adaptive.inner().clone());
910                ind.set_fitness(fit.clone());
911                tracking_population.push(ind);
912            }
913            tracking_population.set_generation(generation);
914
915            // Record statistics
916            let timing = TimingStats::new().with_total(gen_start.elapsed());
917            let gen_stats =
918                GenerationStats::from_population(&tracking_population, generation, evaluations)
919                    .with_timing(timing);
920            fitness_history.push(gen_stats.best_fitness);
921            stats.record(gen_stats);
922        }
923
924        stats.set_runtime(start_time.elapsed());
925
926        Ok(
927            EvolutionResult::new(best.0.into_inner(), best.1, generation, evaluations)
928                .with_stats(stats),
929        )
930    }
931
932    /// Discrete recombination: randomly select genes from parents
933    fn discrete_recombination<R: Rng>(
934        &self,
935        p1: &AdaptiveGenome<G>,
936        p2: &AdaptiveGenome<G>,
937        rng: &mut R,
938    ) -> AdaptiveGenome<G> {
939        let genes1 = p1.inner().genes();
940        let genes2 = p2.inner().genes();
941
942        let child_genes: Vec<f64> = genes1
943            .iter()
944            .zip(genes2.iter())
945            .map(|(g1, g2)| if rng.gen_bool(0.5) { *g1 } else { *g2 })
946            .collect();
947
948        let child_genome = G::from_genes(child_genes).expect("Failed to create genome from genes");
949
950        // Recombine strategy parameters
951        match (&p1.strategy, &p2.strategy) {
952            (StrategyParams::Isotropic(s1), StrategyParams::Isotropic(s2)) => {
953                AdaptiveGenome::new_isotropic(child_genome, (s1 * s2).sqrt())
954            }
955            (StrategyParams::NonIsotropic(s1), StrategyParams::NonIsotropic(s2)) => {
956                let sigmas: Vec<f64> = s1
957                    .iter()
958                    .zip(s2.iter())
959                    .map(|(a, b)| if rng.gen_bool(0.5) { *a } else { *b })
960                    .collect();
961                AdaptiveGenome::new_non_isotropic(child_genome, sigmas)
962            }
963            _ => AdaptiveGenome::new_isotropic(child_genome, self.config.initial_sigma),
964        }
965    }
966
967    /// Intermediate recombination: average of two parents
968    fn intermediate_recombination(
969        &self,
970        p1: &AdaptiveGenome<G>,
971        p2: &AdaptiveGenome<G>,
972    ) -> AdaptiveGenome<G> {
973        let genes1 = p1.inner().genes();
974        let genes2 = p2.inner().genes();
975
976        let child_genes: Vec<f64> = genes1
977            .iter()
978            .zip(genes2.iter())
979            .map(|(g1, g2)| (g1 + g2) / 2.0)
980            .collect();
981
982        let child_genome = G::from_genes(child_genes).expect("Failed to create genome from genes");
983
984        // Average strategy parameters
985        match (&p1.strategy, &p2.strategy) {
986            (StrategyParams::Isotropic(s1), StrategyParams::Isotropic(s2)) => {
987                AdaptiveGenome::new_isotropic(child_genome, (s1 * s2).sqrt())
988            }
989            (StrategyParams::NonIsotropic(s1), StrategyParams::NonIsotropic(s2)) => {
990                let sigmas: Vec<f64> = s1
991                    .iter()
992                    .zip(s2.iter())
993                    .map(|(a, b)| (a * b).sqrt())
994                    .collect();
995                AdaptiveGenome::new_non_isotropic(child_genome, sigmas)
996            }
997            _ => AdaptiveGenome::new_isotropic(child_genome, self.config.initial_sigma),
998        }
999    }
1000
1001    /// Global intermediate recombination: average of all parents
1002    fn global_intermediate_recombination(
1003        &self,
1004        population: &[(AdaptiveGenome<G>, F)],
1005    ) -> AdaptiveGenome<G> {
1006        let n = population.len();
1007        let dim = population[0].0.inner().genes().len();
1008
1009        let mut child_genes = vec![0.0; dim];
1010        for (adaptive, _) in population {
1011            for (i, gene) in adaptive.inner().genes().iter().enumerate() {
1012                child_genes[i] += gene / n as f64;
1013            }
1014        }
1015
1016        let child_genome = G::from_genes(child_genes).expect("Failed to create genome from genes");
1017
1018        // Average all strategy parameters
1019        let avg_sigma = if self.config.self_adaptive {
1020            // Average the sigmas from all parents
1021            let sigmas: Vec<f64> = (0..dim)
1022                .map(|i| {
1023                    let sum: f64 = population
1024                        .iter()
1025                        .map(|(a, _)| a.strategy.get_sigma(i))
1026                        .sum();
1027                    sum / n as f64
1028                })
1029                .collect();
1030            AdaptiveGenome::new_non_isotropic(child_genome, sigmas)
1031        } else {
1032            AdaptiveGenome::new_isotropic(child_genome, self.config.initial_sigma)
1033        };
1034
1035        avg_sigma
1036    }
1037
1038    /// Mutate an adaptive genome
1039    fn mutate<R: Rng>(&self, mut genome: AdaptiveGenome<G>, rng: &mut R) -> AdaptiveGenome<G> {
1040        let n = genome.inner().genes().len();
1041
1042        // Self-adaptive: mutate strategy parameters first
1043        if self.config.self_adaptive {
1044            genome
1045                .strategy
1046                .mutate(n, self.config.resolved_min_sigma(), rng);
1047        }
1048
1049        // Collect sigmas first to avoid borrow conflicts
1050        let sigmas: Vec<f64> = (0..n).map(|i| genome.strategy.get_sigma(i)).collect();
1051
1052        // Then mutate the genome using the (possibly updated) strategy
1053        let genes = genome.inner_mut().genes_mut();
1054        for i in 0..genes.len() {
1055            let perturbation: f64 = rng.sample(StandardNormal);
1056            genes[i] += sigmas[i] * perturbation;
1057
1058            // Clamp to bounds
1059            if let Some(b) = self.bounds.get(i) {
1060                genes[i] = genes[i].clamp(b.min, b.max);
1061            }
1062        }
1063
1064        genome
1065    }
1066}
1067
1068/// Type alias for (μ+λ)-ES
1069pub type MuPlusLambdaES<G, F, Fit, Term> = EvolutionStrategy<G, F, Fit, Term>;
1070
1071/// Type alias for (μ,λ)-ES
1072pub type MuCommaLambdaES<G, F, Fit, Term> = EvolutionStrategy<G, F, Fit, Term>;
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use crate::fitness::benchmarks::Sphere;
1078    use crate::genome::real_vector::RealVector;
1079    use crate::termination::MaxEvaluations;
1080    use rand::SeedableRng;
1081
1082    #[test]
1083    fn test_es_builder() {
1084        let bounds = MultiBounds::symmetric(5.0, 10);
1085        let es: Result<EvolutionStrategy<RealVector, f64, _, _>, _> = ESBuilder::new()
1086            .mu(15)
1087            .lambda(100)
1088            .bounds(bounds)
1089            .fitness(Sphere::new(10))
1090            .max_generations(10)
1091            .build();
1092
1093        assert!(es.is_ok());
1094    }
1095
1096    #[test]
1097    fn test_mu_plus_lambda_es() {
1098        let mut rng = rand::thread_rng();
1099        let bounds = MultiBounds::symmetric(5.12, 10);
1100
1101        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::mu_plus_lambda(10, 70)
1102            .initial_sigma(1.0)
1103            .self_adaptive(true)
1104            .bounds(bounds)
1105            .fitness(Sphere::new(10))
1106            .termination(MaxEvaluations::new(3000))
1107            .build()
1108            .unwrap();
1109
1110        let result = es.run(&mut rng).unwrap();
1111
1112        // Should find improvement
1113        assert!(
1114            result.best_fitness > -50.0,
1115            "Expected fitness > -50, got {}",
1116            result.best_fitness
1117        );
1118    }
1119
1120    #[test]
1121    fn test_mu_comma_lambda_es() {
1122        let mut rng = rand::thread_rng();
1123        let bounds = MultiBounds::symmetric(5.12, 10);
1124
1125        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::mu_comma_lambda(10, 70)
1126            .unwrap()
1127            .initial_sigma(1.0)
1128            .self_adaptive(true)
1129            .bounds(bounds)
1130            .fitness(Sphere::new(10))
1131            .termination(MaxEvaluations::new(3000))
1132            .build()
1133            .unwrap();
1134
1135        let result = es.run(&mut rng).unwrap();
1136
1137        // Should find improvement (may be less effective than μ+λ without elitism)
1138        assert!(
1139            result.best_fitness > -100.0,
1140            "Expected fitness > -100, got {}",
1141            result.best_fitness
1142        );
1143    }
1144
1145    #[test]
1146    fn test_mu_comma_lambda_constraint() {
1147        // λ < μ should fail
1148        let result = ESConfig::mu_comma_lambda(50, 30);
1149        assert!(result.is_err());
1150    }
1151
1152    #[test]
1153    fn test_recombination_types() {
1154        let mut rng = rand::thread_rng();
1155        let bounds = MultiBounds::symmetric(5.12, 5);
1156
1157        let recomb_types = vec![
1158            RecombinationType::None,
1159            RecombinationType::Discrete,
1160            RecombinationType::Intermediate,
1161            RecombinationType::GlobalIntermediate,
1162        ];
1163
1164        for recomb in recomb_types {
1165            let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::new()
1166                .mu(10)
1167                .lambda(50)
1168                .recombination(recomb)
1169                .bounds(bounds.clone())
1170                .fitness(Sphere::new(5))
1171                .termination(MaxEvaluations::new(500))
1172                .build()
1173                .unwrap();
1174
1175            let result = es.run(&mut rng);
1176            assert!(result.is_ok());
1177        }
1178    }
1179
1180    #[test]
1181    fn test_es_self_adaptive_disabled() {
1182        let mut rng = rand::thread_rng();
1183        let bounds = MultiBounds::symmetric(5.12, 5);
1184
1185        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::new()
1186            .mu(10)
1187            .lambda(50)
1188            .self_adaptive(false)
1189            .initial_sigma(0.5)
1190            .bounds(bounds)
1191            .fitness(Sphere::new(5))
1192            .termination(MaxEvaluations::new(500))
1193            .build()
1194            .unwrap();
1195
1196        let result = es.run(&mut rng);
1197        assert!(result.is_ok());
1198    }
1199
1200    // regression: EV-34 — run_with_callback reports every generation and lets a
1201    // caller cancel early.
1202    #[test]
1203    fn test_es_run_with_callback_reports_progress() {
1204        let mut rng = rand::rngs::StdRng::seed_from_u64(11);
1205        let bounds = MultiBounds::symmetric(5.12, 4);
1206        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::new()
1207            .mu(6)
1208            .lambda(24)
1209            .initial_sigma(0.5)
1210            .bounds(bounds)
1211            .fitness(Sphere::new(4))
1212            .max_generations(15)
1213            .build()
1214            .unwrap();
1215
1216        let mut seen: Vec<usize> = Vec::new();
1217        let result = es
1218            .run_with_callback(&mut rng, |generation, best| {
1219                assert!(best.is_finite());
1220                seen.push(generation);
1221                true
1222            })
1223            .unwrap();
1224
1225        // The callback fired once per evolved generation, in order 0,1,2,...
1226        assert_eq!(seen, (0..result.generations).collect::<Vec<_>>());
1227        assert!(!seen.is_empty());
1228    }
1229
1230    #[test]
1231    fn test_es_run_with_callback_cancels_early() {
1232        let mut rng = rand::rngs::StdRng::seed_from_u64(12);
1233        let bounds = MultiBounds::symmetric(5.12, 4);
1234        // A large generation budget the cancel must cut short.
1235        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::new()
1236            .mu(6)
1237            .lambda(24)
1238            .initial_sigma(0.5)
1239            .bounds(bounds)
1240            .fitness(Sphere::new(4))
1241            .max_generations(10_000)
1242            .build()
1243            .unwrap();
1244
1245        let mut calls = 0usize;
1246        let result = es
1247            .run_with_callback(&mut rng, |_generation, _best| {
1248                calls += 1;
1249                // Continue for 5 generations, then cancel.
1250                calls < 5
1251            })
1252            .unwrap();
1253
1254        // Cancelled at generation 4 (0-indexed), so no more than 5 generations ran
1255        // despite the 10k budget.
1256        assert!(calls <= 5, "callback should stop being called after cancel");
1257        assert!(
1258            result.generations < 10,
1259            "run must stop far short of the 10k budget, got {}",
1260            result.generations
1261        );
1262    }
1263
1264    /// regression: EV-40 — the default configuration enables σ self-adaptation,
1265    /// so it must default to comma `(μ,λ)` selection, not elitist plus, which
1266    /// would suppress step-size adaptation. Pre-fix the default was
1267    /// `MuPlusLambda`.
1268    #[test]
1269    fn test_default_selection_is_comma() {
1270        let config = ESConfig::default();
1271        assert!(config.self_adaptive);
1272        assert!(
1273            matches!(config.selection, ESSelectionStrategy::MuCommaLambda),
1274            "self-adaptive default must use (μ,λ) comma selection"
1275        );
1276        // The canonical default (15,100) satisfies the comma constraint λ ≥ μ.
1277        assert!(config.lambda >= config.mu);
1278    }
1279
1280    /// regression: EV-62 — `min_sigma` must resolve to a meaningful,
1281    /// problem-scaled floor (`1e-8 * initial_sigma`) by default and honor an
1282    /// explicit override. Pre-fix there was no such configurable floor at all.
1283    #[test]
1284    fn test_resolved_min_sigma() {
1285        let mut config = ESConfig {
1286            initial_sigma: 2.0,
1287            ..Default::default()
1288        };
1289        assert!((config.resolved_min_sigma() - 2e-8).abs() < 1e-18);
1290
1291        config.min_sigma = Some(0.01);
1292        assert!((config.resolved_min_sigma() - 0.01).abs() < 1e-18);
1293    }
1294
1295    /// regression: EV-62 — with a configured `min_sigma`, the self-adaptive step
1296    /// sizes carried through a full ES run must never collapse below the floor.
1297    #[test]
1298    fn test_min_sigma_builder_threads_through() {
1299        let bounds = MultiBounds::symmetric(5.12, 5);
1300        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::new()
1301            .mu(5)
1302            .lambda(35)
1303            .self_adaptive(true)
1304            .initial_sigma(1.0)
1305            .min_sigma(0.05)
1306            .bounds(bounds)
1307            .fitness(Sphere::new(5))
1308            .termination(MaxEvaluations::new(700))
1309            .build()
1310            .unwrap();
1311        // The configured floor is wired into the run without panicking; the
1312        // resolved floor equals the explicit value.
1313        let mut rng = rand::thread_rng();
1314        assert!(es.run(&mut rng).is_ok());
1315    }
1316
1317    #[test]
1318    fn test_es_bounds_respected() {
1319        let mut rng = rand::thread_rng();
1320        let bounds = MultiBounds::symmetric(2.0, 5);
1321
1322        let es: EvolutionStrategy<RealVector, f64, _, _> = ESBuilder::new()
1323            .mu(10)
1324            .lambda(50)
1325            .initial_sigma(5.0) // Large sigma to test bounds
1326            .bounds(bounds.clone())
1327            .fitness(Sphere::new(5))
1328            .termination(MaxEvaluations::new(500))
1329            .build()
1330            .unwrap();
1331
1332        let result = es.run(&mut rng).unwrap();
1333
1334        // All genes should be within bounds
1335        for gene in result.best_genome.genes() {
1336            assert!(
1337                *gene >= -2.0 && *gene <= 2.0,
1338                "Gene {} outside bounds [-2.0, 2.0]",
1339                gene
1340            );
1341        }
1342    }
1343}