Skip to main content

rlevo_evolution/algorithms/
es_classical.rs

1//! Classical Evolution Strategies.
2//!
3//! Four canonical variants parameterized on a single [`EsConfig`]:
4//!
5//! - `(1+1)` — a single parent, a single offspring, 1/5th success-rule
6//!   σ adaptation.
7//! - `(1+λ)` — a single parent, λ offspring per generation; the best
8//!   offspring replaces the parent iff its fitness improves. The
9//!   underlying mutation/selection loop is also reused by Cartesian GP.
10//! - `(μ,λ)` — μ parents, λ offspring; parents are discarded each
11//!   generation.
12//! - `(μ+λ)` — μ parents, λ offspring; survivors are the μ best of the
13//!   combined pool.
14//!
15//! σ adaptation is by log-normal self-adaptation in the multi-parent
16//! variants; `(1+1)` uses Rechenberg's 1/5th success rule.
17//!
18//! # References
19//!
20//! - Beyer & Schwefel (2002), *Evolution strategies: A comprehensive
21//!   introduction*.
22
23use std::marker::PhantomData;
24
25use burn::tensor::{Tensor, TensorData, backend::Backend};
26use rand::Rng;
27use rand::RngExt;
28
29use rlevo_core::bounds::Bounds;
30use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
31
32use crate::ops::mutation::gaussian_mutation_per_row;
33use crate::ops::replacement::{mu_comma_lambda, mu_plus_lambda};
34use crate::ops::selection::argmax_host;
35use crate::rng::{SeedPurpose, seed_stream};
36use crate::strategy::{Strategy, StrategyMetrics};
37
38/// Which selection scheme the ES uses.
39#[derive(Debug, Clone, Copy)]
40pub enum EsKind {
41    /// `(1+1)` with 1/5-rule σ adaptation.
42    OnePlusOne,
43    /// `(1+λ)` with shared σ across offspring.
44    OnePlusLambda { lambda: usize },
45    /// `(μ,λ)` with log-normal per-individual σ adaptation.
46    MuCommaLambda { mu: usize, lambda: usize },
47    /// `(μ+λ)` with log-normal per-individual σ adaptation.
48    MuPlusLambda { mu: usize, lambda: usize },
49}
50
51impl EsKind {
52    /// Returns the effective offspring-population size for this variant.
53    #[must_use]
54    pub fn population_size(&self) -> usize {
55        match self {
56            EsKind::OnePlusOne => 1,
57            EsKind::OnePlusLambda { lambda }
58            | EsKind::MuCommaLambda { lambda, .. }
59            | EsKind::MuPlusLambda { lambda, .. } => *lambda,
60        }
61    }
62}
63
64/// Default σ floor for the self-adaptive step size (see
65/// [`EsConfig::sigma_min`]).
66const DEFAULT_SIGMA_MIN: f32 = 1e-8;
67/// Default σ ceiling for the self-adaptive step size (see
68/// [`EsConfig::sigma_max`]).
69const DEFAULT_SIGMA_MAX: f32 = 1e6;
70
71/// Static configuration for an [`EvolutionStrategy`] run.
72#[derive(Debug, Clone)]
73pub struct EsConfig {
74    /// Variant to run.
75    pub kind: EsKind,
76    /// Genome dimensionality.
77    pub genome_dim: usize,
78    /// Search-space bounds; used for initialization and clamping.
79    pub bounds: Bounds,
80    /// Initial σ (log-normal self-adaptation modifies it in state).
81    pub initial_sigma: f32,
82    /// Lower clamp for the self-adaptive σ.
83    ///
84    /// Both the log-normal update `σ' = σ · exp(τ · N(0,1))` (multi-parent
85    /// variants) and the Rechenberg 1/5-rule (`(1+1)`) are unbounded
86    /// multiplicative processes; without a floor σ can underflow toward `0`,
87    /// collapsing the mutation amplitude so the search freezes. Must be
88    /// strictly positive and `< sigma_max`. Default `DEFAULT_SIGMA_MIN`.
89    pub sigma_min: f32,
90    /// Upper clamp for the self-adaptive σ.
91    ///
92    /// Without a ceiling σ can overflow toward `+∞` (genes then saturate to a
93    /// bound with no error). Default `DEFAULT_SIGMA_MAX` — far outside any
94    /// practical step scale on the `[-5.12, 5.12]` benchmark domain, so it
95    /// never binds in normal operation and only catches a runaway process.
96    pub sigma_max: f32,
97    /// Learning-rate scale for log-normal σ update. Standard default is
98    /// `1.0 / sqrt(2 * sqrt(D))`.
99    pub tau: f32,
100}
101
102impl EsConfig {
103    /// Default configuration for a given ES variant and dimensionality.
104    ///
105    /// Sets `bounds = (-5.12, 5.12)` (the standard Rastrigin/sphere domain),
106    /// `initial_sigma = 1.0`, and τ via the standard formula
107    /// `1 / sqrt(2 · sqrt(D))` (Beyer & Schwefel 2002, eq. 12).
108    #[must_use]
109    pub fn default_for(kind: EsKind, genome_dim: usize) -> Self {
110        #[allow(clippy::cast_precision_loss)]
111        let d = genome_dim as f32;
112        let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
113        Self {
114            kind,
115            genome_dim,
116            bounds: Bounds::new(-5.12, 5.12),
117            initial_sigma: 1.0,
118            sigma_min: DEFAULT_SIGMA_MIN,
119            sigma_max: DEFAULT_SIGMA_MAX,
120            tau,
121        }
122    }
123}
124
125impl Validate for EsConfig {
126    fn validate(&self) -> Result<(), ConfigError> {
127        const C: &str = "EsConfig";
128        config::nonzero(C, "genome_dim", self.genome_dim)?;
129        config::positive(C, "initial_sigma", f64::from(self.initial_sigma))?;
130        config::positive(C, "sigma_min", f64::from(self.sigma_min))?;
131        config::ordered(
132            C,
133            "sigma_max",
134            f64::from(self.sigma_min),
135            f64::from(self.sigma_max),
136        )?;
137        config::positive(C, "tau", f64::from(self.tau))?;
138        match self.kind {
139            EsKind::OnePlusOne => {}
140            EsKind::OnePlusLambda { lambda } => {
141                config::at_least(C, "lambda", lambda, 1)?;
142            }
143            EsKind::MuPlusLambda { mu, lambda } => {
144                config::at_least(C, "mu", mu, 1)?;
145                config::at_least(C, "lambda", lambda, 1)?;
146            }
147            EsKind::MuCommaLambda { mu, lambda } => {
148                config::at_least(C, "mu", mu, 1)?;
149                config::at_least(C, "lambda", lambda, 1)?;
150                if lambda < mu {
151                    return Err(ConfigError {
152                        config: C,
153                        field: "lambda",
154                        kind: ConstraintKind::Custom("(mu, lambda) requires lambda >= mu"),
155                    });
156                }
157            }
158        }
159        Ok(())
160    }
161}
162
163/// Generation state for [`EvolutionStrategy`].
164#[derive(Debug, Clone)]
165pub struct EsState<B: Backend> {
166    /// Parent population. `(μ, D)` for μ-parent variants; `(1, D)` for
167    /// (1+1) and (1+λ).
168    parents: Tensor<B, 2>,
169    /// Per-parent σ values.
170    ///
171    /// Shape between generations is `(μ,)` for log-normal adaptation and
172    /// `(1,)` for `(1+1)`/`(1+λ)` with shared σ. Between an `ask` and the
173    /// matching `tell` the tensor is temporarily `(μ + λ,)`: parent σ
174    /// followed by per-offspring σ. See `ask` for the rationale.
175    sigmas: Tensor<B, 1>,
176    /// Parent fitnesses.
177    parent_fitness: Vec<f32>,
178    /// Best-so-far genome, shape `(1, D)`.
179    best_genome: Option<Tensor<B, 2>>,
180    /// Best-so-far fitness.
181    best_fitness: f32,
182    /// Completed-generation counter.
183    generation: usize,
184    /// (1+1) only: running success-rate counter for the 1/5th rule.
185    successes_in_window: u32,
186    /// (1+1) only: window length observed so far.
187    window_len: u32,
188}
189
190impl<B: Backend> EsState<B> {
191    /// Assembles an ES state, checking the parent fitness cache matches the
192    /// parent count.
193    ///
194    /// # Errors
195    ///
196    /// Returns a [`ConfigError`] if `parents` has zero rows or if
197    /// `parent_fitness` is non-empty with a length other than the parent
198    /// count `μ` (`parents.dims()[0]`). The bootstrap state (empty
199    /// `parent_fitness`) is accepted.
200    #[allow(clippy::too_many_arguments)]
201    pub fn try_new(
202        parents: Tensor<B, 2>,
203        sigmas: Tensor<B, 1>,
204        parent_fitness: Vec<f32>,
205        best_genome: Option<Tensor<B, 2>>,
206        best_fitness: f32,
207        generation: usize,
208        successes_in_window: u32,
209        window_len: u32,
210    ) -> Result<Self, ConfigError> {
211        let mu = parents.dims()[0];
212        config::nonzero("EsState", "parents", mu)?;
213        if !parent_fitness.is_empty() && parent_fitness.len() != mu {
214            return Err(ConfigError {
215                config: "EsState",
216                field: "parent_fitness",
217                kind: ConstraintKind::Custom("length must equal the parent count μ"),
218            });
219        }
220        Ok(Self {
221            parents,
222            sigmas,
223            parent_fitness,
224            best_genome,
225            best_fitness,
226            generation,
227            successes_in_window,
228            window_len,
229        })
230    }
231
232    /// Parent population, shape `(μ, D)` (or `(1, D)` for `(1+1)`/`(1+λ)`).
233    #[must_use]
234    pub fn parents(&self) -> &Tensor<B, 2> {
235        &self.parents
236    }
237
238    /// Per-parent σ values (see the field docs for the transient `(μ + λ,)`
239    /// shape held between `ask` and `tell`).
240    #[must_use]
241    pub fn sigmas(&self) -> &Tensor<B, 1> {
242        &self.sigmas
243    }
244
245    /// Parent fitnesses (empty at bootstrap, else `μ` long).
246    #[must_use]
247    pub fn parent_fitness(&self) -> &[f32] {
248        &self.parent_fitness
249    }
250
251    /// Best-so-far genome (shape `(1, D)`), or `None` before the first `tell`.
252    #[must_use]
253    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
254        self.best_genome.as_ref()
255    }
256
257    /// Best-so-far (canonical, maximise) fitness.
258    #[must_use]
259    pub fn best_fitness(&self) -> f32 {
260        self.best_fitness
261    }
262
263    /// Completed-generation counter.
264    #[must_use]
265    pub fn generation(&self) -> usize {
266        self.generation
267    }
268
269    /// `(1+1)` only: running success count for the 1/5th rule.
270    #[must_use]
271    pub fn successes_in_window(&self) -> u32 {
272        self.successes_in_window
273    }
274
275    /// `(1+1)` only: window length observed so far.
276    #[must_use]
277    pub fn window_len(&self) -> u32 {
278        self.window_len
279    }
280}
281
282/// Classical Evolution Strategy.
283///
284/// # Example
285///
286/// ```no_run
287/// use burn::backend::Flex;
288/// use rlevo_evolution::algorithms::es_classical::{EsConfig, EsKind, EvolutionStrategy};
289///
290/// let strategy = EvolutionStrategy::<Flex>::new();
291/// let params = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10);
292/// let _ = (strategy, params);
293/// ```
294#[derive(Debug, Clone, Copy, Default)]
295pub struct EvolutionStrategy<B: Backend> {
296    _backend: PhantomData<fn() -> B>,
297}
298
299impl<B: Backend> EvolutionStrategy<B> {
300    /// Builds a new (stateless) strategy object.
301    #[must_use]
302    pub fn new() -> Self {
303        Self {
304            _backend: PhantomData,
305        }
306    }
307
308    fn mu(kind: EsKind) -> usize {
309        match kind {
310            EsKind::OnePlusOne | EsKind::OnePlusLambda { .. } => 1,
311            EsKind::MuCommaLambda { mu, .. } | EsKind::MuPlusLambda { mu, .. } => mu,
312        }
313    }
314
315    fn sample_initial_parents(
316        params: &EsConfig,
317        rng: &mut dyn Rng,
318        device: &<B as burn::tensor::backend::BackendTypes>::Device,
319    ) -> (Tensor<B, 2>, Tensor<B, 1>) {
320        let mu = Self::mu(params.kind);
321        let (lo, hi): (f32, f32) = params.bounds.into();
322        // Host-sample the initial parents from a deterministic `seed_stream`
323        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
324        // whose draws interleave with sibling tests under the parallel runner
325        // and are not reproducible across thread schedules.
326        let genome_dim = params.genome_dim;
327        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
328        let mut parent_rows = Vec::with_capacity(mu * genome_dim);
329        for _ in 0..mu * genome_dim {
330            parent_rows.push(lo + (hi - lo) * stream.random::<f32>());
331        }
332        let parents =
333            Tensor::<B, 2>::from_data(TensorData::new(parent_rows, [mu, genome_dim]), device);
334        let sigmas = Tensor::<B, 1>::from_data(
335            TensorData::new(vec![params.initial_sigma; mu], [mu]),
336            device,
337        );
338        (parents, sigmas)
339    }
340}
341
342impl<B: Backend> Strategy<B> for EvolutionStrategy<B>
343where
344    B::Device: Clone,
345{
346    type Params = EsConfig;
347    type State = EsState<B>;
348    type Genome = Tensor<B, 2>;
349
350    /// Samples the initial parent population uniformly from `params.bounds`
351    /// via a deterministic `seed_stream` (host-RNG convention) and
352    /// initializes all σ values to `params.initial_sigma`.
353    fn init(
354        &self,
355        params: &EsConfig,
356        rng: &mut dyn Rng,
357        device: &<B as burn::tensor::backend::BackendTypes>::Device,
358    ) -> EsState<B> {
359        debug_assert!(
360            params.validate().is_ok(),
361            "invalid EsConfig reached init: {params:?}"
362        );
363        let (parents, sigmas) = Self::sample_initial_parents(params, rng, device);
364        EsState {
365            parents,
366            sigmas,
367            parent_fitness: Vec::new(),
368            best_genome: None,
369            best_fitness: f32::NEG_INFINITY,
370            generation: 0,
371            successes_in_window: 0,
372            window_len: 0,
373        }
374    }
375
376    /// Generates the offspring population for the current generation.
377    ///
378    /// On the very first call (before any `tell`), returns the initial parents
379    /// unchanged so that they can be fitness-evaluated as the seed population.
380    /// On subsequent calls, duplicates parents by uniform random selection,
381    /// applies log-normal σ adaptation (multi-parent variants) or inherits the
382    /// shared σ (`(1+1)` / `(1+λ)`), then mutates via per-individual Gaussian
383    /// noise. All stochastic draws go through `seed_stream`
384    /// (host-RNG convention); offspring σ values are appended to
385    /// `state.sigmas` for consumption by `tell`.
386    fn ask(
387        &self,
388        params: &EsConfig,
389        state: &EsState<B>,
390        rng: &mut dyn Rng,
391        device: &<B as burn::tensor::backend::BackendTypes>::Device,
392    ) -> (Tensor<B, 2>, EsState<B>) {
393        // First call: evaluate the initial parents as the "offspring"
394        // so fitness is populated in the subsequent `tell`.
395        if state.parent_fitness.is_empty() {
396            return (state.parents.clone(), state.clone());
397        }
398
399        let lambda = params.kind.population_size();
400        let mu = Self::mu(params.kind);
401
402        let mut mutation_rng = seed_stream(
403            rng.next_u64(),
404            state.generation as u64,
405            SeedPurpose::Mutation,
406        );
407        let mut sigma_rng =
408            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
409
410        // Build an offspring population of size λ by sampling a parent
411        // index per offspring and mutating. Uniform random parent
412        // selection — no fitness pressure applied at this stage in
413        // classical ES; survivor selection provides the pressure.
414        let mut parent_indices: Vec<i64> = Vec::with_capacity(lambda);
415        for _ in 0..lambda {
416            #[allow(clippy::cast_possible_wrap)]
417            parent_indices.push(sigma_rng.random_range(0..mu) as i64);
418        }
419        let idx_tensor = Tensor::<B, 1, burn::tensor::Int>::from_data(
420            TensorData::new(parent_indices.clone(), [lambda]),
421            device,
422        );
423        let duplicated_parents = state.parents.clone().select(0, idx_tensor.clone());
424        let duplicated_sigmas = state.sigmas.clone().select(0, idx_tensor);
425
426        // Apply log-normal σ adaptation (multi-parent case) or keep σ
427        // shared (1+1 / 1+λ). Log-normal: σ' = σ * exp(τ · N(0,1)).
428        let is_one_plus = matches!(
429            params.kind,
430            EsKind::OnePlusOne | EsKind::OnePlusLambda { .. }
431        );
432        let offspring_sigmas = if is_one_plus {
433            duplicated_sigmas
434        } else {
435            // Host-sample the N(0,1) noise from the deterministic `sigma_rng`
436            // so the log-normal σ update is reproducible across schedules.
437            let mut noise_rows = Vec::with_capacity(lambda);
438            for _ in 0..lambda {
439                noise_rows.push(crate::sampling::standard_normal(&mut sigma_rng));
440            }
441            let noise = Tensor::<B, 1>::from_data(TensorData::new(noise_rows, [lambda]), device);
442            // Clamp the log-normal random walk to `[sigma_min, sigma_max]` so σ
443            // can neither underflow to 0 (search freezes) nor overflow to +∞
444            // (genes saturate). Both bounds are construction-validated.
445            (duplicated_sigmas * noise.mul_scalar(params.tau).exp())
446                .clamp(params.sigma_min, params.sigma_max)
447        };
448
449        // Mutate parents by the per-offspring σ, drawing from the host
450        // `mutation_rng`.
451        let mutated = gaussian_mutation_per_row(
452            duplicated_parents,
453            offspring_sigmas.clone(),
454            &mut mutation_rng,
455            device,
456        );
457
458        // Clamp to bounds.
459        let (lo, hi): (f32, f32) = params.bounds.into();
460        let mutated = mutated.clamp(lo, hi);
461
462        let mut state = state.clone();
463        // Carry offspring σ to `tell` by appending them to `state.sigmas`.
464        // After this point sigmas has shape `(μ + λ,)`: the first μ entries
465        // are the unchanged parent σ, the last λ are the per-offspring σ.
466        // `tell` slices both halves to align survivor σ with survivor genomes
467        // (`(μ+λ)` selection draws from the union, `(μ,λ)` only from the λ
468        // offspring slice). Folding the offspring σ into the existing field
469        // avoids adding a transient pending-σ field to `EsState`.
470        let combined_sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
471        state.sigmas = combined_sigmas;
472        (mutated, state)
473    }
474
475    /// Applies variant-specific selection and σ adaptation, then returns the
476    /// updated state and a per-generation metrics snapshot.
477    ///
478    /// Variant behaviour:
479    /// - `(1+1)`: greedy replacement; σ updated by Rechenberg's 1/5th
480    ///   success rule every `10·D` steps.
481    /// - `(1+λ)`: best offspring replaces the parent only if it strictly
482    ///   improves fitness; σ is carried over unchanged.
483    /// - `(μ,λ)`: selects the μ best offspring; parent pool discarded.
484    ///   Survivor σ values are gathered by the same truncation indices.
485    /// - `(μ+λ)`: selects the μ best of the combined parent + offspring
486    ///   pool. Survivor σ values are drawn from the concatenated σ vector
487    ///   by the same indices.
488    ///
489    /// The first `tell` after `init` bootstraps `parent_fitness` from the
490    /// initial-population evaluation rather than running selection.
491    #[allow(clippy::too_many_lines)]
492    fn tell(
493        &self,
494        params: &EsConfig,
495        offspring: Tensor<B, 2>,
496        fitness: Tensor<B, 1>,
497        mut state: EsState<B>,
498        _rng: &mut dyn Rng,
499    ) -> (EsState<B>, StrategyMetrics) {
500        let fitness_host = fitness
501            .into_data()
502            .into_vec::<f32>()
503            .expect("fitness tensor must be readable as f32");
504
505        // First `tell` after `init`: offspring here is actually the
506        // initial parent population evaluated.
507        if state.parent_fitness.is_empty() {
508            state.parent_fitness.clone_from(&fitness_host);
509            state.generation += 1;
510            update_best(&mut state, &offspring, &fitness_host);
511            let m = StrategyMetrics::from_host_fitness(
512                state.generation,
513                &fitness_host,
514                state.best_fitness,
515            );
516            state.best_fitness = m.best_fitness_ever();
517            state.parents = offspring;
518            // Restore parent-count σ vector.
519            let mu = Self::mu(params.kind);
520            let device = state.parents.device();
521            state.sigmas = Tensor::<B, 1>::from_data(
522                TensorData::new(vec![params.initial_sigma; mu], [mu]),
523                &device,
524            );
525            return (state, m);
526        }
527
528        let device = offspring.device();
529        let mu = Self::mu(params.kind);
530        // state.sigmas currently holds parent σ concatenated with
531        // offspring σ, per `ask`'s scratchpad trick.
532        let lambda = params.kind.population_size();
533        #[allow(clippy::single_range_in_vec_init)]
534        let parent_sigmas = state.sigmas.clone().slice([0..mu]);
535        #[allow(clippy::single_range_in_vec_init)]
536        let offspring_sigmas = state.sigmas.clone().slice([mu..(mu + lambda)]);
537
538        match params.kind {
539            EsKind::OnePlusOne => {
540                // One parent, one offspring. Fitness[0] is the offspring.
541                let parent_fit = state.parent_fitness[0];
542                let offspring_fit = fitness_host[0];
543                let success = offspring_fit > parent_fit;
544                state.window_len += 1;
545                if success {
546                    state.successes_in_window += 1;
547                    state.parents.clone_from(&offspring);
548                    state.parent_fitness = vec![offspring_fit];
549                }
550                // Rechenberg 1/5-rule every 10 · D generations.
551                #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
552                let window = 10_u32.saturating_mul(params.genome_dim as u32).max(1);
553                if state.window_len >= window {
554                    #[allow(clippy::cast_precision_loss)]
555                    let rate = state.successes_in_window as f32 / state.window_len as f32;
556                    let current_sigma = state
557                        .sigmas
558                        .clone()
559                        .into_data()
560                        .into_vec::<f32>()
561                        .expect("sigma tensor must be readable as f32")[0];
562                    // The 1/5-rule is also an unbounded multiplicative process;
563                    // clamp to the same construction-validated window so σ can
564                    // neither underflow to 0 nor overflow to +∞ over a long run.
565                    let new_sigma = if rate > 0.2 {
566                        current_sigma * 1.22
567                    } else if rate < 0.2 {
568                        current_sigma / 1.22
569                    } else {
570                        current_sigma
571                    }
572                    .clamp(params.sigma_min, params.sigma_max);
573                    state.sigmas =
574                        Tensor::<B, 1>::from_data(TensorData::new(vec![new_sigma], [1]), &device);
575                    state.successes_in_window = 0;
576                    state.window_len = 0;
577                } else {
578                    state.sigmas = parent_sigmas;
579                }
580            }
581            EsKind::OnePlusLambda { .. } => {
582                // Best of (parent, offspring pool).
583                let best_off_idx = argmax_host(&fitness_host);
584                let best_off_fit = fitness_host[best_off_idx];
585                if best_off_fit > state.parent_fitness[0] {
586                    #[allow(clippy::single_range_in_vec_init)]
587                    let best_row = offspring.clone().slice([best_off_idx..best_off_idx + 1]);
588                    state.parents = best_row;
589                    state.parent_fitness = vec![best_off_fit];
590                }
591                state.sigmas = parent_sigmas;
592            }
593            EsKind::MuCommaLambda { mu, .. } => {
594                let (survivors, survivor_f) =
595                    mu_comma_lambda::<B>(offspring.clone(), &fitness_host, mu, &device);
596                // Gather survivor σs matching the same indices.
597                let survivor_idx =
598                    crate::ops::selection::truncation_indices_host(&fitness_host, mu);
599                let survivor_sigmas = offspring_sigmas.select(
600                    0,
601                    Tensor::<B, 1, burn::tensor::Int>::from_data(
602                        TensorData::new(survivor_idx, [mu]),
603                        &device,
604                    ),
605                );
606                state.parents = survivors;
607                state.parent_fitness = survivor_f;
608                state.sigmas = survivor_sigmas;
609            }
610            EsKind::MuPlusLambda { mu, .. } => {
611                let (survivors, survivor_f) = mu_plus_lambda::<B>(
612                    state.parents.clone(),
613                    &state.parent_fitness,
614                    offspring.clone(),
615                    &fitness_host,
616                    mu,
617                    &device,
618                );
619                // Survivor σ via truncation_indices_host on the combined fitness.
620                let combined_f: Vec<f32> = state
621                    .parent_fitness
622                    .iter()
623                    .chain(fitness_host.iter())
624                    .copied()
625                    .collect();
626                let survivor_idx = crate::ops::selection::truncation_indices_host(&combined_f, mu);
627                let combined_sigmas = Tensor::cat(vec![parent_sigmas, offspring_sigmas], 0);
628                let survivor_sigmas = combined_sigmas.select(
629                    0,
630                    Tensor::<B, 1, burn::tensor::Int>::from_data(
631                        TensorData::new(survivor_idx, [mu]),
632                        &device,
633                    ),
634                );
635                state.parents = survivors;
636                state.parent_fitness = survivor_f;
637                state.sigmas = survivor_sigmas;
638            }
639        }
640
641        state.generation += 1;
642        update_best(&mut state, &offspring, &fitness_host);
643        let m =
644            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
645        state.best_fitness = m.best_fitness_ever();
646        (state, m)
647    }
648
649    /// Returns the best-so-far genome and its fitness, or `None` before the
650    /// first `tell` call.
651    fn best(&self, state: &EsState<B>) -> Option<(Tensor<B, 2>, f32)> {
652        state
653            .best_genome
654            .as_ref()
655            .map(|g| (g.clone(), state.best_fitness))
656    }
657}
658
659fn update_best<B: Backend>(state: &mut EsState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
660    if fitness.is_empty() {
661        return;
662    }
663    let best_idx = argmax_host(fitness);
664    let best_f = fitness[best_idx];
665    if best_f > state.best_fitness {
666        let device = pop.device();
667        #[allow(clippy::cast_possible_wrap)]
668        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
669            TensorData::new(vec![best_idx as i64], [1]),
670            &device,
671        );
672        state.best_genome = Some(pop.clone().select(0, idx));
673        state.best_fitness = best_f;
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::fitness::FromFitnessEvaluable;
681    use crate::strategy::EvolutionaryHarness;
682    use burn::backend::Flex;
683    use rlevo_core::fitness::FitnessEvaluable;
684    type TestBackend = Flex;
685
686    #[test]
687    fn try_new_checks_parent_fitness_length() {
688        let device = Default::default();
689        let parents = Tensor::<TestBackend, 2>::zeros([4, 2], &device);
690        let sigmas = Tensor::<TestBackend, 1>::ones([4], &device);
691        // Bootstrap (empty) and fully-populated caches both accept.
692        assert!(
693            EsState::try_new(
694                parents.clone(),
695                sigmas.clone(),
696                vec![],
697                None,
698                f32::MIN,
699                0,
700                0,
701                0
702            )
703            .is_ok()
704        );
705        assert!(
706            EsState::try_new(
707                parents.clone(),
708                sigmas.clone(),
709                vec![1.0; 4],
710                None,
711                1.0,
712                1,
713                0,
714                0,
715            )
716            .is_ok()
717        );
718        // parent_fitness length 3 ≠ μ = 4.
719        assert!(EsState::try_new(parents, sigmas, vec![1.0; 3], None, 1.0, 1, 0, 0).is_err());
720    }
721
722    #[test]
723    fn default_config_validates() {
724        let cfg = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10);
725        assert!(cfg.validate().is_ok());
726    }
727
728    #[test]
729    fn rejects_comma_lambda_below_mu() {
730        let cfg = EsConfig::default_for(EsKind::MuCommaLambda { mu: 10, lambda: 5 }, 10);
731        assert_eq!(cfg.validate().unwrap_err().field, "lambda");
732    }
733
734    /// `genome_dim == 0` makes `tau = 1/sqrt(2·sqrt(0)) = +∞`; the config guard
735    /// must reject it at construction (ADR 0026) so the non-finite τ never
736    /// reaches the first `ask` (issue #132, `es_classical` §1.1 / `ep` §1.2).
737    #[test]
738    fn rejects_zero_genome_dim() {
739        let cfg = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 0);
740        assert!(
741            !cfg.tau.is_finite(),
742            "precondition: derived tau is non-finite for genome_dim == 0, got {}",
743            cfg.tau
744        );
745        assert_eq!(
746            cfg.validate().unwrap_err().field,
747            "genome_dim",
748            "genome_dim == 0 must be rejected before the non-finite tau can be used"
749        );
750    }
751
752    /// An inverted σ window (`sigma_min >= sigma_max`) is rejected so the clamp
753    /// bounds are always a valid interval (`es_classical` §1.1).
754    #[test]
755    fn rejects_inverted_sigma_window() {
756        let mut cfg = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10);
757        cfg.sigma_min = 10.0;
758        cfg.sigma_max = 1.0;
759        assert_eq!(
760            cfg.validate().unwrap_err().field,
761            "sigma_max",
762            "sigma_min >= sigma_max must be rejected"
763        );
764    }
765
766    /// The log-normal σ of a multi-parent variant stays inside
767    /// `[sigma_min, sigma_max]` across many generations even under an aggressive
768    /// `tau` that would otherwise drive the walk to `0` or `+∞`
769    /// (`es_classical` §1.1). Drives the strategy directly so the transient
770    /// `(μ + λ,)` σ vector produced by `ask` is inspected.
771    #[test]
772    fn sigma_stays_within_bounds_across_updates() {
773        use rand::SeedableRng;
774        use rand::rngs::StdRng;
775
776        let device = Default::default();
777        let strategy = EvolutionStrategy::<TestBackend>::new();
778        let mut params = EsConfig::default_for(EsKind::MuPlusLambda { mu: 4, lambda: 12 }, 3);
779        params.tau = 5.0;
780        params.sigma_min = 1e-4;
781        params.sigma_max = 10.0;
782        assert!(params.validate().is_ok(), "test config must be valid");
783
784        let mut rng = StdRng::seed_from_u64(9);
785        let mut state = strategy.init(&params, &mut rng, &device);
786        for generation in 0..60 {
787            let (offspring, next) = strategy.ask(&params, &state, &mut rng, &device);
788            let sigmas: Vec<f32> = next
789                .sigmas()
790                .clone()
791                .into_data()
792                .into_vec::<f32>()
793                .expect("sigma host-read of a tensor this test just built");
794            for &s in &sigmas {
795                assert!(
796                    s.is_finite() && s >= params.sigma_min && s <= params.sigma_max,
797                    "σ left [{}, {}] at gen {generation}: {s}",
798                    params.sigma_min,
799                    params.sigma_max
800                );
801            }
802            let n = offspring.dims()[0];
803            let fitness = Tensor::<TestBackend, 1>::from_data(
804                TensorData::new(vec![1.0_f32; n], [n]),
805                &device,
806            );
807            let (advanced, _) = strategy.tell(&params, offspring, fitness, next, &mut rng);
808            state = advanced;
809        }
810    }
811
812    /// Both the plain argmax and the truncation selector used by ES survivor
813    /// selection must return valid, in-range, pairwise-distinct indices even
814    /// when the fitness slice carries `NaN` values (fitness-hygiene: a `NaN`
815    /// ranks as worst and never as a survivor). Guards against an
816    /// out-of-bounds gather in `tell` (`es_classical` §7.2, valid index).
817    #[test]
818    fn selection_returns_in_range_indices() {
819        let fitness = [1.0_f32, f32::NAN, 5.0, 2.0, -3.0, f32::NAN];
820        let n = fitness.len();
821
822        let amax = argmax_host(&fitness);
823        assert!(amax < n, "argmax index {amax} out of range for len {n}");
824
825        for mu in 1..=4 {
826            let idx = crate::ops::selection::truncation_indices_host(&fitness, mu);
827            assert_eq!(idx.len(), mu, "truncation must return exactly mu indices");
828            for (a, &x) in idx.iter().enumerate() {
829                assert!(
830                    usize::try_from(x).is_ok_and(|xi| xi < n),
831                    "truncation index {x} out of range for len {n}"
832                );
833                for &y in &idx[a + 1..] {
834                    assert_ne!(x, y, "truncation indices must be pairwise distinct");
835                }
836            }
837        }
838    }
839
840    /// Canonical (maximise) fitness `−Σ xᵢ²` read straight off a genome tensor,
841    /// so tests can drive a strategy directly without the harness.
842    fn neg_sphere(pop: &Tensor<TestBackend, 2>) -> Tensor<TestBackend, 1> {
843        let device = pop.device();
844        let [n, d] = pop.dims();
845        let rows: Vec<f32> = pop
846            .clone()
847            .into_data()
848            .into_vec::<f32>()
849            .expect("population host-read of a tensor this test just built");
850        #[allow(clippy::needless_range_loop)]
851        let fit: Vec<f32> = (0..n)
852            .map(|i| -(0..d).map(|j| rows[i * d + j].powi(2)).sum::<f32>())
853            .collect();
854        Tensor::<TestBackend, 1>::from_data(TensorData::new(fit, [n]), &device)
855    }
856
857    /// Drives an ES variant directly for `gens` generations against the
858    /// canonical maximise `−sphere`, returning the `best_fitness_ever`
859    /// trajectory reported by each `tell`.
860    fn run_es_best_ever(kind: EsKind, dim: usize, gens: usize, seed: u64) -> Vec<f32> {
861        use rand::SeedableRng;
862        use rand::rngs::StdRng;
863
864        let device = Default::default();
865        let strategy = EvolutionStrategy::<TestBackend>::new();
866        let params = EsConfig::default_for(kind, dim);
867        let mut rng = StdRng::seed_from_u64(seed);
868        let mut state = strategy.init(&params, &mut rng, &device);
869        let mut traj = Vec::with_capacity(gens);
870        for _ in 0..gens {
871            let (offspring, next) = strategy.ask(&params, &state, &mut rng, &device);
872            let fitness = neg_sphere(&offspring);
873            let (advanced, m) = strategy.tell(&params, offspring, fitness, next, &mut rng);
874            traj.push(m.best_fitness_ever());
875            state = advanced;
876        }
877        traj
878    }
879
880    /// `best_fitness_ever` is a rolling maximum in canonical space, so on a
881    /// maximise problem it must never decrease from one generation to the next
882    /// across every ES variant (`es_classical` §7.2, monotone best-ever). This
883    /// pins the invariant that the algorithm threads the rolling best through
884    /// `tell` and never resets it — including the `(μ,λ)` variant that discards
885    /// its parent pool each generation.
886    #[test]
887    fn best_fitness_ever_is_monotonic_on_maximize() {
888        for kind in [
889            EsKind::OnePlusOne,
890            EsKind::OnePlusLambda { lambda: 6 },
891            EsKind::MuPlusLambda { mu: 3, lambda: 8 },
892            EsKind::MuCommaLambda { mu: 3, lambda: 8 },
893        ] {
894            let traj = run_es_best_ever(kind, 3, 40, 17);
895            for w in traj.windows(2) {
896                assert!(
897                    w[1] >= w[0],
898                    "best_fitness_ever decreased for {kind:?}: {} -> {}",
899                    w[0],
900                    w[1]
901                );
902            }
903            assert!(
904                traj.last().copied().unwrap().is_finite(),
905                "rolling best must stay finite for {kind:?}"
906            );
907        }
908    }
909
910    struct Sphere;
911    struct SphereFit;
912    impl FitnessEvaluable for SphereFit {
913        type Individual = Vec<f64>;
914        type Landscape = Sphere;
915        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
916            x.iter().map(|v| v * v).sum()
917        }
918    }
919
920    fn run_es(kind: EsKind, dim: usize, generations: usize, seed: u64) -> f32 {
921        let device = Default::default();
922        let strategy = EvolutionStrategy::<TestBackend>::new();
923        let params = EsConfig::default_for(kind, dim);
924        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
925        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
926            strategy,
927            params,
928            fitness_fn,
929            seed,
930            device,
931            generations,
932        )
933        .expect("valid params");
934        harness.reset();
935        loop {
936            let step = harness.step(());
937            if step.done {
938                break;
939            }
940        }
941        harness.latest_metrics().unwrap().best_fitness_ever()
942    }
943
944    #[test]
945    fn one_plus_lambda_converges_on_sphere_d2() {
946        let best = run_es(EsKind::OnePlusLambda { lambda: 8 }, 2, 200, 7);
947        assert!(best < 1e-2, "OnePlusLambda best={best}");
948    }
949
950    #[test]
951    fn one_plus_one_converges_on_sphere_d2() {
952        let best = run_es(EsKind::OnePlusOne, 2, 500, 11);
953        assert!(best < 1e-2, "OnePlusOne best={best}");
954    }
955
956    #[test]
957    fn mu_plus_lambda_converges_on_sphere_d2() {
958        let best = run_es(EsKind::MuPlusLambda { mu: 3, lambda: 8 }, 2, 200, 7);
959        assert!(best < 1e-2, "MuPlusLambda best={best}");
960    }
961
962    #[test]
963    fn mu_comma_lambda_converges_on_sphere_d2() {
964        let best = run_es(EsKind::MuCommaLambda { mu: 3, lambda: 8 }, 2, 200, 7);
965        assert!(best < 1e-1, "MuCommaLambda best={best}");
966    }
967
968    #[test]
969    fn mu_plus_lambda_converges_on_sphere_d10() {
970        // Convergence on Sphere (D=10) to best_fitness < 1e-6 within
971        // budget on Flex. We allow a generous budget because the
972        // classical ES is slower than CMA-ES; the goal is to verify
973        // convergence direction, not to optimize hyperparameters.
974        let best = run_es(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10, 1500, 42);
975        assert!(best < 1e-6, "MuPlusLambda D10 best={best}");
976    }
977}