Skip to main content

rlevo_evolution/algorithms/
ep.rs

1//! Evolutionary Programming (Fogel-style).
2//!
3//! Classical EP differs from ES in the details:
4//!
5//! - **No crossover**. Each parent produces exactly one offspring by
6//!   Gaussian mutation.
7//! - **Self-adaptive σ**. Each individual carries its own σ, updated
8//!   by the log-normal rule `σ' = σ · exp(τ · N(0, 1))`. This is the
9//!   same mechanism and ordering as the multi-parent ES variants: σ is
10//!   perturbed first, and the updated σ' drives that individual's gene
11//!   mutation. Survivor σ are inherited, not reset.
12//! - **q-tournament survivor selection** on the `(μ + μ)` pool. Each
13//!   individual plays `q` random opponents; the μ individuals with the
14//!   highest win-counts survive. This diverges from truncation
15//!   selection — EP gives weaker individuals a stochastic chance to
16//!   survive.
17//!
18//! # Reference
19//!
20//! - Fogel (1994), *An introduction to simulated evolutionary
21//!   optimization*.
22
23use std::marker::PhantomData;
24
25use burn::tensor::{Int, 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::rng::{SeedPurpose, seed_stream};
34use crate::strategy::{Strategy, StrategyMetrics};
35
36/// Default σ floor for the log-normal self-adaptation (see
37/// [`EpConfig::sigma_min`]).
38const DEFAULT_SIGMA_MIN: f32 = 1e-8;
39/// Default σ ceiling for the log-normal self-adaptation (see
40/// [`EpConfig::sigma_max`]).
41const DEFAULT_SIGMA_MAX: f32 = 1e6;
42
43/// Static configuration for an [`EvolutionaryProgramming`] run.
44#[derive(Debug, Clone)]
45pub struct EpConfig {
46    /// Parent population size (offspring population is also μ — EP is
47    /// strictly `μ + μ`).
48    pub mu: usize,
49    /// Genome dimensionality.
50    pub genome_dim: usize,
51    /// Search-space bounds (initialization and clamping).
52    pub bounds: Bounds,
53    /// Initial σ for every individual.
54    pub initial_sigma: f32,
55    /// Lower clamp for the self-adaptive σ.
56    ///
57    /// The log-normal update `σ' = σ · exp(τ · N(0,1))` is an unbounded
58    /// multiplicative random walk; without a floor σ can underflow toward
59    /// `0`, collapsing the mutation amplitude so the search freezes. Must be
60    /// strictly positive and `< sigma_max`. Default `DEFAULT_SIGMA_MIN`.
61    pub sigma_min: f32,
62    /// Upper clamp for the self-adaptive σ.
63    ///
64    /// Without a ceiling the log-normal update can overflow toward `+∞`
65    /// (genes then saturate to a bound with no error). Default
66    /// `DEFAULT_SIGMA_MAX` — far outside any practical step scale on the
67    /// `[-5.12, 5.12]` benchmark domain, so it never binds in normal
68    /// operation and only catches a runaway walk.
69    pub sigma_max: f32,
70    /// Learning rate for the log-normal σ update. Default is
71    /// `1 / sqrt(2 · sqrt(D))`.
72    pub tau: f32,
73    /// Number of opponents per tournament round (q-tournament).
74    pub tournament_q: usize,
75}
76
77impl EpConfig {
78    /// Default configuration for a given dimensionality.
79    ///
80    /// Sets `initial_sigma = 1.0`, `tournament_q = 10`, and derives
81    /// `tau = 1.0 / sqrt(2.0 · sqrt(D))` — the standard EP learning-rate
82    /// recommendation from Fogel (1994). Bounds are `(-5.12, 5.12)`.
83    #[must_use]
84    pub fn default_for(mu: usize, genome_dim: usize) -> Self {
85        #[allow(clippy::cast_precision_loss)]
86        let d = genome_dim as f32;
87        let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
88        Self {
89            mu,
90            genome_dim,
91            bounds: Bounds::new(-5.12, 5.12),
92            initial_sigma: 1.0,
93            sigma_min: DEFAULT_SIGMA_MIN,
94            sigma_max: DEFAULT_SIGMA_MAX,
95            tau,
96            tournament_q: 10,
97        }
98    }
99}
100
101impl Validate for EpConfig {
102    fn validate(&self) -> Result<(), ConfigError> {
103        const C: &str = "EpConfig";
104        config::at_least(C, "mu", self.mu, 1)?;
105        config::nonzero(C, "genome_dim", self.genome_dim)?;
106        config::positive(C, "initial_sigma", f64::from(self.initial_sigma))?;
107        config::positive(C, "sigma_min", f64::from(self.sigma_min))?;
108        config::ordered(
109            C,
110            "sigma_max",
111            f64::from(self.sigma_min),
112            f64::from(self.sigma_max),
113        )?;
114        config::positive(C, "tau", f64::from(self.tau))?;
115        config::at_least(C, "tournament_q", self.tournament_q, 1)?;
116        if self.tournament_q > 2 * self.mu {
117            return Err(ConfigError {
118                config: C,
119                field: "tournament_q",
120                kind: ConstraintKind::Custom("tournament_q must not exceed 2 * mu"),
121            });
122        }
123        Ok(())
124    }
125}
126
127/// Generation-to-generation state for [`EvolutionaryProgramming`].
128///
129/// The two-phase ask/tell handshake uses `parent_fitness.is_empty()` as
130/// a sentinel: on the very first [`Strategy::ask`] call the initial
131/// parents are returned unchanged; on the very first [`Strategy::tell`]
132/// call `parent_fitness` is populated and
133/// `best_genome`/`best_fitness` are initialized. Subsequent
134/// ask/tell cycles produce, evaluate, and select from the `(μ + μ)` pool.
135///
136/// During `ask`, `sigmas` is temporarily expanded to length `2μ` (parent
137/// σ concatenated with offspring σ) so `tell` can apply q-tournament
138/// selection over the combined pool without re-deriving σ values. After
139/// `tell` completes, `sigmas` is back to length `μ`.
140#[derive(Debug, Clone)]
141pub struct EpState<B: Backend> {
142    /// Current parents, shape `(μ, D)`.
143    pub parents: Tensor<B, 2>,
144    /// Per-individual step-size σ, shape `(μ,)` between generations and
145    /// `(2μ,)` transiently inside an ask/tell cycle (parent σ ‖ offspring σ).
146    pub sigmas: Tensor<B, 1>,
147    /// Host-side fitness cache for the current parents.
148    ///
149    /// Empty before the first [`Strategy::tell`] call; length `μ`
150    /// thereafter. The `is_empty()` check distinguishes the initial
151    /// evaluation phase from subsequent tournament-selection generations.
152    pub parent_fitness: Vec<f32>,
153    /// Best-so-far genome, shape `(1, D)`.
154    ///
155    /// `None` before the first [`Strategy::tell`] call.
156    pub best_genome: Option<Tensor<B, 2>>,
157    /// Best-so-far fitness across all completed generations.
158    ///
159    /// `f32::NEG_INFINITY` before the first [`Strategy::tell`] call
160    /// (the worst value under the maximise convention).
161    pub best_fitness: f32,
162    /// Number of completed `tell` calls (zero-based generation index + 1).
163    pub generation: usize,
164}
165
166/// Classical Fogel EP.
167///
168/// # Example
169///
170/// ```no_run
171/// use burn::backend::Flex;
172/// use rlevo_evolution::algorithms::ep::{EpConfig, EvolutionaryProgramming};
173///
174/// let strategy = EvolutionaryProgramming::<Flex>::new();
175/// let params = EpConfig::default_for(30, 10);
176/// let _ = (strategy, params);
177/// ```
178#[derive(Debug, Clone, Copy, Default)]
179pub struct EvolutionaryProgramming<B: Backend> {
180    _backend: PhantomData<fn() -> B>,
181}
182
183impl<B: Backend> EvolutionaryProgramming<B> {
184    /// Builds a new (stateless) strategy object.
185    #[must_use]
186    pub fn new() -> Self {
187        Self {
188            _backend: PhantomData,
189        }
190    }
191}
192
193impl<B: Backend> Strategy<B> for EvolutionaryProgramming<B>
194where
195    B::Device: Clone,
196{
197    type Params = EpConfig;
198    type State = EpState<B>;
199    type Genome = Tensor<B, 2>;
200
201    /// Samples the initial parent population uniformly within
202    /// `params.bounds`, initializes per-parent σ to
203    /// `params.initial_sigma`, and returns an [`EpState`] with an empty
204    /// fitness cache.
205    ///
206    /// Initial sampling goes through [`seed_stream`] rather than
207    /// `B::seed + Tensor::random` to keep results reproducible across
208    /// parallel test threads.
209    fn init(
210        &self,
211        params: &EpConfig,
212        rng: &mut dyn Rng,
213        device: &<B as burn::tensor::backend::BackendTypes>::Device,
214    ) -> EpState<B> {
215        debug_assert!(
216            params.validate().is_ok(),
217            "invalid EpConfig reached init: {params:?}"
218        );
219        let (lo, hi): (f32, f32) = params.bounds.into();
220        // Host-sample the initial parents from a deterministic `seed_stream`
221        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
222        // whose draws interleave with sibling tests under the parallel runner
223        // and are not reproducible across thread schedules.
224        let mu = params.mu;
225        let genome_dim = params.genome_dim;
226        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
227        let mut parent_rows = Vec::with_capacity(mu * genome_dim);
228        for _ in 0..mu * genome_dim {
229            parent_rows.push(lo + (hi - lo) * stream.random::<f32>());
230        }
231        let parents =
232            Tensor::<B, 2>::from_data(TensorData::new(parent_rows, [mu, genome_dim]), device);
233        let sigmas = Tensor::<B, 1>::from_data(
234            TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
235            device,
236        );
237        EpState {
238            parents,
239            sigmas,
240            parent_fitness: Vec::new(),
241            best_genome: None,
242            best_fitness: f32::NEG_INFINITY,
243            generation: 0,
244        }
245    }
246
247    /// Proposes the offspring population for this generation.
248    ///
249    /// **First call (fitness cache empty):** returns the initial parents
250    /// unchanged so the caller can evaluate them before any mutation step.
251    ///
252    /// **Subsequent calls:**
253    ///
254    /// 1. Applies the log-normal σ update to each parent:
255    ///    `σ'_i = σ_i · exp(τ · N(0, 1))`, host-sampled via
256    ///    [`seed_stream`] with [`SeedPurpose::Other`].
257    /// 2. Mutates each parent by its updated σ using
258    ///    [`gaussian_mutation_per_row`], host-sampled via [`seed_stream`]
259    ///    with [`SeedPurpose::Mutation`].
260    /// 3. Clamps offspring to `params.bounds`.
261    /// 4. Appends the offspring σ values to `state.sigmas`, making it
262    ///    length `2μ` so [`Strategy::tell`] can select over the combined
263    ///    pool without re-deriving them.
264    ///
265    /// Returns the offspring tensor and the updated state.
266    fn ask(
267        &self,
268        params: &EpConfig,
269        state: &EpState<B>,
270        rng: &mut dyn Rng,
271        device: &<B as burn::tensor::backend::BackendTypes>::Device,
272    ) -> (Tensor<B, 2>, EpState<B>) {
273        // First call: evaluate the initial parents.
274        if state.parent_fitness.is_empty() {
275            return (state.parents.clone(), state.clone());
276        }
277
278        let mu = params.mu;
279        let mut sigma_rng =
280            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
281        let mut mutation_rng = seed_stream(
282            rng.next_u64(),
283            state.generation as u64,
284            SeedPurpose::Mutation,
285        );
286
287        // Log-normal σ update for every parent. Host-sample the N(0,1)
288        // noise from the deterministic `sigma_rng` so it is reproducible
289        // across thread schedules.
290        let mut noise_rows = Vec::with_capacity(mu);
291        for _ in 0..mu {
292            noise_rows.push(crate::sampling::standard_normal(&mut sigma_rng));
293        }
294        let noise = Tensor::<B, 1>::from_data(TensorData::new(noise_rows, [mu]), device);
295        // Clamp the log-normal random walk to `[sigma_min, sigma_max]` so σ can
296        // neither underflow to 0 (search freezes) nor overflow to +∞ (genes
297        // saturate). Both bounds are construction-validated on `EpConfig`.
298        let offspring_sigmas = (state.sigmas.clone() * noise.mul_scalar(params.tau).exp())
299            .clamp(params.sigma_min, params.sigma_max);
300
301        // Mutate each parent exactly once using its own σ, drawing from the
302        // host `mutation_rng`.
303        let offspring = gaussian_mutation_per_row(
304            state.parents.clone(),
305            offspring_sigmas.clone(),
306            &mut mutation_rng,
307            device,
308        );
309        let (lo, hi): (f32, f32) = params.bounds.into();
310        let offspring = offspring.clamp(lo, hi);
311
312        // Stash offspring σ onto state via concatenation (parent_σ || offspring_σ).
313        let mut state = state.clone();
314        state.sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
315        (offspring, state)
316    }
317
318    /// Consumes the evaluated offspring and advances the state.
319    ///
320    /// **First call (fitness cache empty):** stores the initial parent
321    /// fitness, initializes `best_genome`/`best_fitness`, resets σ to
322    /// `params.initial_sigma`, and increments the generation counter.
323    ///
324    /// **Subsequent calls:**
325    ///
326    /// 1. Builds the `(μ + μ)` combined pool of parents and offspring
327    ///    (and their `2μ` σ values from [`Strategy::ask`]).
328    /// 2. Runs q-tournament selection: each of the `2μ` members plays
329    ///    `params.tournament_q` random opponents; the member wins a bout
330    ///    if its fitness is strictly higher. The μ members with the most
331    ///    wins survive; ties are broken by fitness (higher wins).
332    ///    Tournament indices are host-sampled via [`seed_stream`] with
333    ///    [`SeedPurpose::Selection`].
334    /// 3. Updates `best_genome`/`best_fitness` from the offspring
335    ///    fitness if improved.
336    ///
337    /// Returns the updated [`EpState`] and a [`StrategyMetrics`] snapshot
338    /// covering the current offspring generation's fitness distribution.
339    fn tell(
340        &self,
341        params: &EpConfig,
342        offspring: Tensor<B, 2>,
343        fitness: Tensor<B, 1>,
344        mut state: EpState<B>,
345        rng: &mut dyn Rng,
346    ) -> (EpState<B>, StrategyMetrics) {
347        let fitness_host = fitness
348            .into_data()
349            .into_vec::<f32>()
350            .expect("fitness tensor must be readable as f32");
351        let device = offspring.device();
352
353        // First `tell`: evaluated the initial parents.
354        if state.parent_fitness.is_empty() {
355            state.parent_fitness.clone_from(&fitness_host);
356            state.generation += 1;
357            update_best(&mut state, &offspring, &fitness_host);
358            let m = StrategyMetrics::from_host_fitness(
359                state.generation,
360                &fitness_host,
361                state.best_fitness,
362            );
363            state.best_fitness = m.best_fitness_ever();
364            state.parents = offspring;
365            state.sigmas = Tensor::<B, 1>::from_data(
366                TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
367                &device,
368            );
369            return (state, m);
370        }
371
372        let mu = params.mu;
373        // Build the (μ + μ) pool.
374        let combined_pop = Tensor::cat(vec![state.parents.clone(), offspring.clone()], 0);
375        let combined_fit: Vec<f32> = state
376            .parent_fitness
377            .iter()
378            .chain(fitness_host.iter())
379            .copied()
380            .collect();
381        let combined_sigmas = state.sigmas.clone(); // already (μ + μ) thanks to `ask`.
382
383        // q-tournament: for each of the 2μ members, sample q opponents
384        // and count wins (higher fitness beats lower). The μ highest-
385        // win members survive.
386        let mut selection_rng = seed_stream(
387            rng.next_u64(),
388            state.generation as u64,
389            SeedPurpose::Selection,
390        );
391        let n = combined_fit.len();
392        let mut win_counts: Vec<u32> = vec![0; n];
393        for (i, &my_fit) in combined_fit.iter().enumerate() {
394            for _ in 0..params.tournament_q {
395                let opp = selection_rng.random_range(0..n);
396                if my_fit > combined_fit[opp] {
397                    win_counts[i] += 1;
398                }
399            }
400        }
401
402        // Sort by (win_count desc, fitness desc) and pick top μ. Sanitize the
403        // fitness tiebreak (NaN → −inf, worst) so a NaN can never rank as best.
404        let mut indexed: Vec<usize> = (0..n).collect();
405        let sane: Vec<f32> = combined_fit
406            .iter()
407            .map(|&f| crate::fitness::sanitize_fitness(f))
408            .collect();
409        indexed.sort_by(|&a, &b| {
410            win_counts[b]
411                .cmp(&win_counts[a])
412                .then_with(|| sane[b].total_cmp(&sane[a]))
413        });
414        indexed.truncate(mu);
415        #[allow(clippy::cast_possible_wrap)]
416        let survivor_idx: Vec<i64> = indexed.iter().map(|&i| i as i64).collect();
417
418        let idx_tensor =
419            Tensor::<B, 1, Int>::from_data(TensorData::new(survivor_idx.clone(), [mu]), &device);
420        let next_parents = combined_pop.select(0, idx_tensor.clone());
421        let next_sigmas = combined_sigmas.select(0, idx_tensor);
422        let next_fitness: Vec<f32> = survivor_idx
423            .iter()
424            .map(|&i| {
425                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
426                combined_fit[i as usize]
427            })
428            .collect();
429
430        state.parents = next_parents;
431        state.sigmas = next_sigmas;
432        state.parent_fitness = next_fitness;
433        state.generation += 1;
434        update_best(&mut state, &offspring, &fitness_host);
435        let m =
436            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
437        state.best_fitness = m.best_fitness_ever();
438        (state, m)
439    }
440
441    /// Returns the best-so-far genome and its canonical (maximise) fitness.
442    ///
443    /// Returns `None` before the first [`Strategy::tell`] call, when
444    /// `EpState::best_genome` is still `None`.
445    fn best(&self, state: &EpState<B>) -> Option<(Tensor<B, 2>, f32)> {
446        state
447            .best_genome
448            .as_ref()
449            .map(|g| (g.clone(), state.best_fitness))
450    }
451}
452
453fn update_best<B: Backend>(state: &mut EpState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
454    if fitness.is_empty() {
455        return;
456    }
457    let mut best_idx = 0usize;
458    let mut best_f = fitness[0];
459    for (i, &f) in fitness.iter().enumerate().skip(1) {
460        if f > best_f {
461            best_f = f;
462            best_idx = i;
463        }
464    }
465    if best_f > state.best_fitness {
466        let device = pop.device();
467        #[allow(clippy::cast_possible_wrap)]
468        let idx =
469            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
470        state.best_genome = Some(pop.clone().select(0, idx));
471        state.best_fitness = best_f;
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::fitness::FromFitnessEvaluable;
479    use crate::strategy::EvolutionaryHarness;
480    use burn::backend::Flex;
481    use rlevo_core::fitness::FitnessEvaluable;
482    type TestBackend = Flex;
483
484    #[test]
485    fn default_config_validates() {
486        assert!(EpConfig::default_for(30, 10).validate().is_ok());
487    }
488
489    #[test]
490    fn rejects_tournament_q_above_two_mu() {
491        let mut cfg = EpConfig::default_for(5, 10);
492        cfg.tournament_q = 11;
493        assert_eq!(cfg.validate().unwrap_err().field, "tournament_q");
494    }
495
496    /// `μ = 0` is the degenerate empty population; the config guard must reject
497    /// it (`config::at_least("mu", .., 1)`) so no zero-row parent tensor ever
498    /// reaches `init` (`ep` §7, edge case).
499    #[test]
500    fn rejects_zero_mu() {
501        let cfg = EpConfig::default_for(0, 10);
502        assert_eq!(cfg.validate().unwrap_err().field, "mu");
503    }
504
505    /// `μ = 1` is the smallest population the config accepts; it must validate
506    /// and drive without panicking through several generations (`ep` §7, edge
507    /// case — smallest degenerate μ is handled, not rejected).
508    #[test]
509    fn mu_one_is_handled() {
510        use rand::SeedableRng;
511        use rand::rngs::StdRng;
512
513        let device = Default::default();
514        let strategy = EvolutionaryProgramming::<TestBackend>::new();
515        let mut params = EpConfig::default_for(1, 3);
516        // q-tournament needs `q <= 2·μ`; with μ = 1 the ceiling is 2.
517        params.tournament_q = 2;
518        assert!(params.validate().is_ok(), "μ = 1 config must validate");
519
520        let mut rng = StdRng::seed_from_u64(3);
521        let mut state = strategy.init(&params, &mut rng, &device);
522        for _ in 0..10 {
523            let (offspring, next) = strategy.ask(&params, &state, &mut rng, &device);
524            let fitness = neg_sphere(&offspring);
525            let (advanced, _) = strategy.tell(&params, offspring, fitness, next, &mut rng);
526            state = advanced;
527        }
528        assert_eq!(
529            state.parents.dims()[0],
530            1,
531            "μ = 1 must keep a single parent"
532        );
533    }
534
535    /// Canonical (maximise) fitness `−Σ xᵢ²` read straight off a genome tensor,
536    /// so tests can drive a strategy directly without the harness.
537    fn neg_sphere(pop: &Tensor<TestBackend, 2>) -> Tensor<TestBackend, 1> {
538        let device = pop.device();
539        let [n, d] = pop.dims();
540        let rows: Vec<f32> = pop
541            .clone()
542            .into_data()
543            .into_vec::<f32>()
544            .expect("population host-read of a tensor this test just built");
545        #[allow(clippy::needless_range_loop)]
546        let fit: Vec<f32> = (0..n)
547            .map(|i| -(0..d).map(|j| rows[i * d + j].powi(2)).sum::<f32>())
548            .collect();
549        Tensor::<TestBackend, 1>::from_data(TensorData::new(fit, [n]), &device)
550    }
551
552    /// Drives EP for `gens` generations from a fixed seed, returning the
553    /// per-generation `best_fitness_ever` trajectory.
554    fn run_ep_trajectory(seed: u64, gens: usize) -> Vec<f32> {
555        use rand::SeedableRng;
556        use rand::rngs::StdRng;
557
558        let device = Default::default();
559        let strategy = EvolutionaryProgramming::<TestBackend>::new();
560        let params = EpConfig::default_for(8, 3);
561        let mut rng = StdRng::seed_from_u64(seed);
562        let mut state = strategy.init(&params, &mut rng, &device);
563        let mut traj = Vec::with_capacity(gens);
564        for _ in 0..gens {
565            let (offspring, next) = strategy.ask(&params, &state, &mut rng, &device);
566            let fitness = neg_sphere(&offspring);
567            let (advanced, m) = strategy.tell(&params, offspring, fitness, next, &mut rng);
568            traj.push(m.best_fitness_ever());
569            state = advanced;
570        }
571        traj
572    }
573
574    /// Same seed → identical trajectory. Every stochastic draw is host-sampled
575    /// through `seed_stream`, so two runs keyed on the same outer seed must be
576    /// bit-identical (`ep` §7, reproducibility). Both runs execute sequentially
577    /// inside one test body so no sibling test can perturb them.
578    #[test]
579    fn same_seed_reproduces_trajectory() {
580        let a = run_ep_trajectory(2024, 30);
581        let b = run_ep_trajectory(2024, 30);
582        assert_eq!(a, b, "EP trajectory diverged under identical seed");
583    }
584
585    /// The genome reported by [`Strategy::best`] must be the population row that
586    /// actually achieved the reported best fitness (`ep` §7, `best_genome`
587    /// invariant). A strictly increasing fitness makes the argmax the unique
588    /// last row, so the expected genome is unambiguous.
589    #[test]
590    fn best_genome_matches_best_fitness() {
591        use rand::SeedableRng;
592        use rand::rngs::StdRng;
593
594        let device = Default::default();
595        let strategy = EvolutionaryProgramming::<TestBackend>::new();
596        let params = EpConfig::default_for(6, 3);
597        let mut rng = StdRng::seed_from_u64(5);
598        let state = strategy.init(&params, &mut rng, &device);
599        // First ask returns the initial parents; the first tell initializes
600        // best_genome/best_fitness from their evaluation.
601        let (parents0, s) = strategy.ask(&params, &state, &mut rng, &device);
602        let [n, d] = parents0.dims();
603        #[allow(clippy::cast_precision_loss)]
604        let fit_vec: Vec<f32> = (0..n).map(|i| i as f32).collect();
605        let expected_idx = n - 1;
606        let expected_fit = fit_vec[expected_idx];
607        let parent_rows: Vec<f32> = parents0
608            .clone()
609            .into_data()
610            .into_vec::<f32>()
611            .expect("parent host-read of a tensor this test just built");
612        let expected_genome: Vec<f32> =
613            parent_rows[expected_idx * d..(expected_idx + 1) * d].to_vec();
614
615        let fitness = Tensor::<TestBackend, 1>::from_data(TensorData::new(fit_vec, [n]), &device);
616        let (s, _) = strategy.tell(&params, parents0, fitness, s, &mut rng);
617
618        let (genome, best_fit) = strategy.best(&s).expect("best after first tell");
619        approx::assert_relative_eq!(best_fit, expected_fit);
620        let got: Vec<f32> = genome
621            .into_data()
622            .into_vec::<f32>()
623            .expect("best-genome host-read of a tensor this test just built");
624        for (g, e) in got.iter().zip(expected_genome.iter()) {
625            approx::assert_relative_eq!(*g, *e);
626        }
627    }
628
629    /// Sphere landscape returning `NaN` for half the domain (see the DE mirror).
630    struct NanSphere;
631    struct NanSphereFit;
632    impl FitnessEvaluable for NanSphereFit {
633        type Individual = Vec<f64>;
634        type Landscape = NanSphere;
635        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
636            let s: f64 = x.iter().map(|v| v * v).sum();
637            if x[0] > 0.0 { f64::NAN } else { s }
638        }
639    }
640
641    /// A `NaN`-producing fitness must not crash EP nor become the reported best.
642    /// The harness sanitizes `NaN → −∞` before the pool ever reaches
643    /// q-tournament selection, so a poisoned member always loses its bouts and
644    /// the tiebreak `sanitize_fitness` keeps it out of the survivor set
645    /// (`ep` §7, NaN regression).
646    #[test]
647    fn nan_fitness_never_becomes_best() {
648        let device = Default::default();
649        let params = EpConfig::default_for(20, 4);
650        let fitness_fn = FromFitnessEvaluable::new(NanSphereFit, NanSphere);
651        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
652            EvolutionaryProgramming::<TestBackend>::new(),
653            params,
654            fitness_fn,
655            77,
656            device,
657            40,
658        )
659        .expect("valid params");
660        harness.reset();
661        loop {
662            if harness.step(()).done {
663                break;
664            }
665        }
666        let best = harness.latest_metrics().unwrap().best_fitness_ever();
667        assert!(
668            best.is_finite(),
669            "NaN fitness poisoned best_fitness_ever: {best}"
670        );
671    }
672
673    /// `genome_dim == 0` makes `tau = 1/sqrt(2·sqrt(0)) = +∞`; the config guard
674    /// must reject it at construction (ADR 0026) so the non-finite τ never
675    /// reaches the first `ask` (issue #132, `ep` §1.2).
676    #[test]
677    fn rejects_zero_genome_dim() {
678        let cfg = EpConfig::default_for(5, 0);
679        assert!(
680            !cfg.tau.is_finite(),
681            "precondition: derived tau is non-finite for genome_dim == 0, got {}",
682            cfg.tau
683        );
684        assert_eq!(
685            cfg.validate().unwrap_err().field,
686            "genome_dim",
687            "genome_dim == 0 must be rejected before the non-finite tau can be used"
688        );
689    }
690
691    /// An inverted σ window (`sigma_min >= sigma_max`) is rejected so the clamp
692    /// bounds are always a valid interval (`ep` §1.1).
693    #[test]
694    fn rejects_inverted_sigma_window() {
695        let mut cfg = EpConfig::default_for(5, 10);
696        cfg.sigma_min = 10.0;
697        cfg.sigma_max = 1.0;
698        assert_eq!(
699            cfg.validate().unwrap_err().field,
700            "sigma_max",
701            "sigma_min >= sigma_max must be rejected"
702        );
703    }
704
705    /// The self-adaptive σ must stay inside `[sigma_min, sigma_max]` across many
706    /// generations even under an aggressive `tau` that would otherwise drive the
707    /// log-normal random walk to `0` or `+∞` (`ep` §1.1). Drives the strategy
708    /// directly so the transient `(2μ,)` σ vector produced by `ask` is inspected.
709    #[test]
710    fn sigma_stays_within_bounds_across_updates() {
711        use rand::SeedableRng;
712        use rand::rngs::StdRng;
713
714        let device = Default::default();
715        let strategy = EvolutionaryProgramming::<TestBackend>::new();
716        let mut params = EpConfig::default_for(6, 3);
717        // Aggressive τ plus a tight window: without the clamp σ would leave
718        // `[sigma_min, sigma_max]` within a handful of generations.
719        params.tau = 5.0;
720        params.sigma_min = 1e-4;
721        params.sigma_max = 10.0;
722        assert!(params.validate().is_ok(), "test config must be valid");
723
724        let mut rng = StdRng::seed_from_u64(7);
725        let mut state = strategy.init(&params, &mut rng, &device);
726        for generation in 0..60 {
727            let (offspring, next) = strategy.ask(&params, &state, &mut rng, &device);
728            let sigmas: Vec<f32> = next
729                .sigmas
730                .clone()
731                .into_data()
732                .into_vec::<f32>()
733                .expect("sigma host-read of a tensor this test just built");
734            for &s in &sigmas {
735                assert!(
736                    s.is_finite() && s >= params.sigma_min && s <= params.sigma_max,
737                    "σ left [{}, {}] at gen {generation}: {s}",
738                    params.sigma_min,
739                    params.sigma_max
740                );
741            }
742            let n = offspring.dims()[0];
743            let fitness = Tensor::<TestBackend, 1>::from_data(
744                TensorData::new(vec![1.0_f32; n], [n]),
745                &device,
746            );
747            let (advanced, _) = strategy.tell(&params, offspring, fitness, next, &mut rng);
748            state = advanced;
749        }
750    }
751
752    struct Sphere;
753    struct SphereFit;
754    impl FitnessEvaluable for SphereFit {
755        type Individual = Vec<f64>;
756        type Landscape = Sphere;
757        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
758            x.iter().map(|v| v * v).sum()
759        }
760    }
761
762    #[test]
763    fn ep_converges_on_sphere_d2() {
764        let device = Default::default();
765        let params = EpConfig::default_for(10, 2);
766        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
767        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
768            EvolutionaryProgramming::<TestBackend>::new(),
769            params,
770            fitness_fn,
771            3,
772            device,
773            300,
774        )
775        .expect("valid params");
776        harness.reset();
777        loop {
778            if harness.step(()).done {
779                break;
780            }
781        }
782        let best = harness.latest_metrics().unwrap().best_fitness_ever();
783        assert!(best < 1e-2, "EP Sphere-D2 best={best}");
784    }
785
786    #[test]
787    fn ep_converges_on_sphere_d10() {
788        let device = Default::default();
789        let params = EpConfig::default_for(20, 10);
790        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
791        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
792            EvolutionaryProgramming::<TestBackend>::new(),
793            params,
794            fitness_fn,
795            5,
796            device,
797            2000,
798        )
799        .expect("valid params");
800        harness.reset();
801        loop {
802            if harness.step(()).done {
803                break;
804            }
805        }
806        let best = harness.latest_metrics().unwrap().best_fitness_ever();
807        assert!(best < 1e-4, "EP Sphere-D10 best={best}");
808    }
809}