Skip to main content

rlevo_evolution/algorithms/neuroevolution/
neat.rs

1//! NEAT — topology-evolving neuroevolution as a custom harness.
2//!
3//! `rlevo` implements NEAT (`NeuroEvolution` of Augmenting Topologies; Stanley &
4//! Miikkulainen, 2002). [`NeatStrategy`] grows network topology *and* weights
5//! open-endedly from a minimal seed. Like its siblings
6//! [`WeightOnly`](super::weight_only::WeightOnly) and
7//! [`ArchNasStrategy`](super::arch_nas::ArchNasStrategy), it is a **custom
8//! harness** with inherent `init`/`ask`/`tell`/`best` and does **not** implement
9//! [`Strategy`](crate::strategy::Strategy). Its genome is host-side graph data
10//! ([`TopologyGenome`]) with no tensor representation, so the `Strategy<B>`
11//! tensor-genome contract does not fit; the parallel [`GraphFitnessFn`] seam
12//! plays the role [`BatchFitnessFn`](crate::fitness::BatchFitnessFn) plays for
13//! tensor strategies.
14//!
15//! # Orientation — maximization
16//!
17//! NEAT is **maximization** (higher fitness is better), matching the crate-wide
18//! maximise convention (canonical space; see
19//! [`ObjectiveSense`](rlevo_core::objective::ObjectiveSense)). Fitness sharing
20//! additionally assumes **non-negative** raw fitness — a NEAT-specific
21//! precondition orthogonal to objective sense. Cost objectives are reconciled
22//! into canonical (maximise) space by the harness/adapter chokepoint, so a
23//! task never hand-negates here.
24//!
25//! # Generational loop
26//!
27//! The caller drives the loop manually, exactly as the `arch_nas` integration
28//! test does:
29//!
30//! ```ignore
31//! let strat = NeatStrategy::<B>::new();
32//! let mut state = strat.init(&params, &mut rng, &device);
33//! let builder = InterpretedBuilder;
34//! for _ in 0..generations {
35//!     let (population, next) = strat.ask(&params, &state, &mut rng);
36//!     let fitness = graph_fitness.evaluate(&population, &builder, &device);
37//!     state = strat.tell(&params, population, fitness, next, &mut rng);
38//!     if let Some((_, best)) = strat.best(&state) { if best >= target { break; } }
39//! }
40//! ```
41//!
42//! # Determinism and host RNG
43//!
44//! Every stochastic decision derives from a `seed_stream(rng.next_u64(),
45//! generation, SeedPurpose::…)` host-side `StdRng` substream — the crate-wide
46//! host-RNG convention. Combined with the per-run [`InnovationRegistry`]'s
47//! caches, the same
48//! seed + same mutation sequence yields identical innovation *and* node ids.
49//! Never `B::seed_from_u64` + `Tensor::random` (process-wide backend RNG mutex
50//! races parallel tests).
51//!
52//! # Gradient isolation
53//!
54//! Generic over `B: Backend`, never `AutodiffBackend`. The phenotype is a
55//! forward-only bare-tensor evaluator; no autodiff graph is ever built.
56
57use std::collections::{HashMap, HashSet};
58use std::marker::PhantomData;
59use std::sync::Arc;
60
61use burn::tensor::Tensor;
62use burn::tensor::backend::Backend;
63use rand::rngs::StdRng;
64use rand::{Rng, RngExt};
65use rand_distr::{Distribution as _, Normal};
66
67use crate::neuroevolution::innovation::InnovationRegistry;
68use crate::neuroevolution::phenotype::{BatchPhenotypeEvaluator, PhenotypeBuilder};
69use crate::neuroevolution::species::{self, Species, SpeciesId};
70use crate::neuroevolution::topology::{
71    ActivationFn, ConnectionGene, NodeGene, NodeId, NodeKind, TopologyGenome,
72};
73use crate::rng::{SeedPurpose, seed_stream};
74
75/// Static configuration for a NEAT run.
76///
77/// Build the canonical defaults with [`NeatParams::default_for`]; the
78/// compatibility threshold and the structural-mutation rates (`p_add_node`,
79/// `p_add_connection`) are the knobs most worth tuning per task.
80#[derive(Debug, Clone)]
81pub struct NeatParams {
82    /// Number of individuals per generation.
83    pub pop_size: usize,
84    /// Number of input (sensor) nodes.
85    pub num_inputs: usize,
86    /// Number of output nodes.
87    pub num_outputs: usize,
88    /// Excess-gene coefficient `c1` in the compatibility distance.
89    pub c1: f32,
90    /// Disjoint-gene coefficient `c2` in the compatibility distance.
91    pub c2: f32,
92    /// Weight-difference coefficient `c3` in the compatibility distance.
93    pub c3: f32,
94    /// Compatibility-distance threshold below which a genome joins a species.
95    pub compat_threshold: f32,
96    /// Generations of no best-fitness improvement before a species is culled.
97    pub stagnation_limit: u64,
98    /// Fraction of each species (by fitness) eligible to reproduce.
99    pub survival_threshold: f32,
100    /// Species with strictly **more** members than this copy their champion
101    /// unchanged each generation (canonical NEAT: species larger than ~5).
102    pub elitism_min_species_size: usize,
103    /// Per-genome probability that weight mutation occurs at all.
104    pub p_mutate_weight: f32,
105    /// Standard deviation of the Gaussian weight/bias perturbation.
106    pub weight_perturb_std: f32,
107    /// Per-gene probability a weight is replaced (vs. perturbed) when mutating.
108    pub p_weight_replace: f32,
109    /// Per-genome probability of an add-connection mutation.
110    pub p_add_connection: f32,
111    /// Per-genome probability of an add-node mutation.
112    pub p_add_node: f32,
113    /// Per-genome probability of an enable/disable toggle mutation.
114    pub p_toggle_enable: f32,
115    /// Probability a child gene is disabled when disabled in either parent.
116    pub p_disable_inherited: f32,
117    /// Probability a crossover draws its second parent from another species.
118    pub interspecies_mating_rate: f32,
119    /// Fraction of offspring produced by mutation only (no crossover).
120    pub mutate_only_fraction: f32,
121    /// Standard deviation used to initialize (and replace) weights and biases.
122    pub weight_init_std: f32,
123}
124
125impl NeatParams {
126    /// The canonical NEAT defaults (Stanley & Miikkulainen, 2002) for the given
127    /// population and I/O sizes.
128    #[must_use]
129    pub fn default_for(pop_size: usize, num_inputs: usize, num_outputs: usize) -> Self {
130        Self {
131            pop_size,
132            num_inputs,
133            num_outputs,
134            c1: 1.0,
135            c2: 1.0,
136            c3: 0.4,
137            compat_threshold: 3.0,
138            stagnation_limit: 15,
139            survival_threshold: 0.2,
140            elitism_min_species_size: 5,
141            p_mutate_weight: 0.8,
142            weight_perturb_std: 0.5,
143            p_weight_replace: 0.1,
144            p_add_connection: 0.05,
145            p_add_node: 0.03,
146            p_toggle_enable: 0.01,
147            p_disable_inherited: 0.75,
148            interspecies_mating_rate: 0.001,
149            mutate_only_fraction: 0.25,
150            weight_init_std: 1.0,
151        }
152    }
153}
154
155/// Generation-to-generation NEAT state.
156///
157/// `#[derive(Clone)]` is cheap and correct: all fields are host-side data, and
158/// the `registry` field clones the shared `Arc` handle (the per-run registry
159/// itself is not duplicated).
160#[derive(Clone, Debug)]
161pub struct NeatState {
162    /// Current resident population.
163    pub population: Vec<TopologyGenome>,
164    /// Resident fitness (maximization); empty until the first `tell`.
165    pub fitness: Vec<f32>,
166    /// Current species partition over `population`.
167    pub species: Vec<Species>,
168    /// Per-run innovation registry, shared across the harness.
169    pub registry: Arc<InnovationRegistry>,
170    /// Completed-generation counter (number of `tell` calls).
171    pub generation: u64,
172    /// Next species id to allocate.
173    pub next_species_id: SpeciesId,
174    /// Best genome seen across all generations, if any.
175    pub best: Option<TopologyGenome>,
176    /// Best fitness seen across all generations (`−∞` before the first `tell`).
177    pub best_fitness: f32,
178}
179
180/// Custom NEAT harness. Does **not** implement
181/// [`Strategy`](crate::strategy::Strategy) — see the module docs.
182#[derive(Debug, Clone, Copy, Default)]
183pub struct NeatStrategy<B: Backend> {
184    _backend: PhantomData<fn() -> B>,
185}
186
187impl<B: Backend> NeatStrategy<B> {
188    /// Build a new (stateless) NEAT harness.
189    #[must_use]
190    pub fn new() -> Self {
191        Self {
192            _backend: PhantomData,
193        }
194    }
195
196    /// Build the initial state: a per-run registry plus `pop_size` minimal
197    /// genomes (aligned ids, per-individual random weights from one
198    /// [`SeedPurpose::Init`] substream).
199    ///
200    /// `device` is part of the harness signature for symmetry with the tensor
201    /// strategies; NEAT genomes are host-side, so it is unused here.
202    ///
203    /// # Panics
204    ///
205    /// Panics if `weight_init_std` is non-finite (`+∞` or `NaN`).
206    #[must_use]
207    pub fn init(
208        &self,
209        params: &NeatParams,
210        rng: &mut dyn Rng,
211        device: &<B as burn::tensor::backend::BackendTypes>::Device,
212    ) -> NeatState {
213        let _ = device;
214        let registry = Arc::new(InnovationRegistry::new(
215            params.num_inputs + params.num_outputs,
216            params.num_inputs * params.num_outputs,
217        ));
218        let mut init_rng = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
219        let population: Vec<TopologyGenome> = (0..params.pop_size)
220            .map(|_| {
221                TopologyGenome::minimal(
222                    params.num_inputs,
223                    params.num_outputs,
224                    &registry,
225                    &mut init_rng,
226                    params.weight_init_std,
227                )
228            })
229            .collect();
230
231        NeatState {
232            population,
233            fitness: Vec::new(),
234            species: Vec::new(),
235            registry,
236            generation: 0,
237            next_species_id: SpeciesId::new(0),
238            best: None,
239            best_fitness: f32::NEG_INFINITY,
240        }
241    }
242
243    /// Propose the next population.
244    ///
245    /// Before the first [`tell`](Self::tell) (resident fitness still empty), the
246    /// unchanged resident population is returned for evaluation. Afterwards the
247    /// already-speciated residents drive reproduction: stagnant species are
248    /// removed (top-K protected), offspring are apportioned by size-adjusted
249    /// fitness (largest-remainder, summing exactly to `pop_size`), and each
250    /// species contributes its champion unchanged (if large enough) plus
251    /// offspring from intra-species crossover (rare interspecies mating) or
252    /// mutation-only, all followed by the four mutation operators. Four `StdRng`
253    /// substreams (selection, crossover, mutation, misc) keep operator RNG
254    /// independent.
255    #[must_use]
256    pub fn ask(
257        &self,
258        params: &NeatParams,
259        state: &NeatState,
260        rng: &mut dyn Rng,
261    ) -> (Vec<TopologyGenome>, NeatState) {
262        // First call: the seed population has not been evaluated yet.
263        if state.fitness.is_empty() {
264            return (state.population.clone(), state.clone());
265        }
266
267        let generation = state.generation;
268        let mut next = state.clone();
269        species::remove_stagnant(&mut next.species, generation, params.stagnation_limit);
270        let counts = species::allocate_offspring(&next.species, params.pop_size);
271
272        let mut rngs = ReproRngs {
273            selection: seed_stream(rng.next_u64(), generation, SeedPurpose::Selection),
274            crossover: seed_stream(rng.next_u64(), generation, SeedPurpose::Crossover),
275            mutation: seed_stream(rng.next_u64(), generation, SeedPurpose::Mutation),
276            misc: seed_stream(rng.next_u64(), generation, SeedPurpose::Other),
277        };
278
279        let mut offspring: Vec<TopologyGenome> = Vec::with_capacity(params.pop_size);
280        {
281            let ctx = ReproContext {
282                params,
283                population: &state.population,
284                fitness: &state.fitness,
285                species: &next.species,
286                registry: &state.registry,
287            };
288            for (si, &count) in counts.iter().enumerate() {
289                produce_offspring(&ctx, &mut rngs, si, count, &mut offspring);
290            }
291        }
292
293        // Defensive: apportionment + reproduction already total `pop_size`; this
294        // only guards against a degenerate empty-species edge.
295        while offspring.len() < params.pop_size {
296            let idx = rngs.selection.random_range(0..state.population.len());
297            let mut child = state.population[idx].clone();
298            apply_mutations(&mut child, params, &state.registry, &mut rngs.mutation);
299            offspring.push(child);
300        }
301        offspring.truncate(params.pop_size);
302
303        (offspring, next)
304    }
305
306    /// Install the evaluated population and its (maximization) fitness, speciate,
307    /// update per-species best/stagnation and the global best-so-far, and bump
308    /// the generation.
309    ///
310    /// Speciation lives here — not in `ask` — because this is the only point
311    /// where the new population, its fitness, and the prior species' cloned
312    /// representatives all coexist consistently (so member indices stay valid and
313    /// each species' next representative is cloned from a live member).
314    ///
315    /// # Fitness hygiene
316    ///
317    /// This is NEAT's **driver chokepoint** (ADR 0034): NEAT is its own driver
318    /// and does **not** run through
319    /// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness), so there is
320    /// no harness above it to sanitize fitness. `tell` therefore applies
321    /// `sanitize_fitness` to the incoming
322    /// `fitness` (`NaN → −∞`, `+∞ → f32::MAX`, `−∞` and finite pass through)
323    /// **before** it is stored or handed to [`species::speciate`], so a
324    /// non-finite fitness can never poison speciation, offspring apportionment,
325    /// or best-so-far tracking.
326    ///
327    /// # Panics
328    ///
329    /// Panics if `population.len()` differs from `fitness.len()`.
330    #[must_use]
331    pub fn tell(
332        &self,
333        params: &NeatParams,
334        population: Vec<TopologyGenome>,
335        fitness: Vec<f32>,
336        mut state: NeatState,
337        rng: &mut dyn Rng,
338    ) -> NeatState {
339        assert_eq!(
340            population.len(),
341            fitness.len(),
342            "population and fitness must have equal length"
343        );
344        let generation = state.generation;
345        let mut rep_rng = seed_stream(rng.next_u64(), generation, SeedPurpose::Representative);
346
347        state.population = population;
348        // Driver chokepoint (ADR 0034): sanitize before store/speciate. NEAT has
349        // no harness above it, so this is the boundary that neutralizes NaN/±∞.
350        state.fitness = fitness
351            .into_iter()
352            .map(crate::fitness::sanitize_fitness)
353            .collect();
354        species::speciate(
355            &state.population,
356            &state.fitness,
357            &mut state.species,
358            params.c1,
359            params.c2,
360            params.c3,
361            params.compat_threshold,
362            &mut state.next_species_id,
363            generation,
364            &mut rep_rng,
365        );
366
367        // Sanitize NaN → −inf (worst) so a NaN fitness can never become best.
368        if let Some((idx, best)) = state
369            .fitness
370            .iter()
371            .enumerate()
372            .map(|(i, &f)| (i, crate::fitness::sanitize_fitness(f)))
373            .max_by(|(_, a), (_, b)| a.total_cmp(b))
374            && best > state.best_fitness
375        {
376            state.best_fitness = best;
377            state.best = Some(state.population[idx].clone());
378        }
379
380        state.generation += 1;
381        state
382    }
383
384    /// Best genome and its fitness seen across all generations.
385    ///
386    /// Returns `None` before the first [`tell`](Self::tell).
387    #[must_use]
388    pub fn best<'s>(&self, state: &'s NeatState) -> Option<(&'s TopologyGenome, f32)> {
389        state.best.as_ref().map(|g| (g, state.best_fitness))
390    }
391}
392
393/// Evaluation seam for graph genomes — the [`BatchFitnessFn`] analogue for NEAT.
394///
395/// Maximization-oriented (higher is better). The caller drives the generational
396/// loop manually, calling `evaluate` between `ask` and `tell`.
397///
398/// # Example
399///
400/// ```ignore
401/// struct SumOutputs<B: Backend> { inputs: Tensor<B, 2> }
402/// impl<B: Backend> GraphFitnessFn<B> for SumOutputs<B> {
403///     fn evaluate(&self, pop: &[TopologyGenome], builder: &dyn PhenotypeBuilder<B>,
404///                 device: &B::Device) -> Vec<f32> {
405///         pop.iter().map(|g| {
406///             let net = builder.build(g, device);
407///             let out = net.forward(self.inputs.clone());
408///             out.into_data().into_vec::<f32>().expect("output tensor must be readable as f32").iter().sum() // higher = better
409///         }).collect()
410///     }
411/// }
412/// ```
413///
414/// [`BatchFitnessFn`]: crate::fitness::BatchFitnessFn
415pub trait GraphFitnessFn<B: Backend>: Send + Sync {
416    /// Score every genome in `population`, returning one **maximization**
417    /// fitness per genome (higher is better, matching the crate-wide canonical
418    /// convention). Fitness sharing assumes the values are non-negative. The
419    /// returned `Vec` has one entry per input genome, in order. Build each
420    /// genome's network with `builder` on `device`.
421    fn evaluate(
422        &self,
423        population: &[TopologyGenome],
424        builder: &dyn PhenotypeBuilder<B>,
425        device: &<B as burn::tensor::backend::BackendTypes>::Device,
426    ) -> Vec<f32>;
427}
428
429/// A [`GraphFitnessFn`] that scores the whole population in **one** device-resident
430/// pass via a [`BatchPhenotypeEvaluator`], instead of the per-genome interpreted
431/// loop.
432///
433/// It is a drop-in alternative to an interpreted `GraphFitnessFn` in the same
434/// manual `init → ask → evaluate → tell` loop: the `builder` argument is ignored
435/// (the batched evaluator owns evaluation), so the harness stays
436/// evaluation-agnostic and `NeatStrategy`'s determinism is untouched.
437///
438/// The `reducer` maps one genome's `batch × action_dim` output slab (row-major,
439/// as produced by [`BatchPhenotypeEvaluator::evaluate_population`]) to a single
440/// **maximization** fitness — the batched analogue of the per-genome scoring an
441/// interpreted `GraphFitnessFn` does on `phenotype.forward(...)`.
442pub struct BatchGraphFitness<B: Backend, E> {
443    evaluator: E,
444    obs: Tensor<B, 2>,
445    #[allow(clippy::type_complexity)]
446    reducer: Box<dyn Fn(&[f32]) -> f32 + Send + Sync>,
447}
448
449impl<B: Backend, E> BatchGraphFitness<B, E> {
450    /// Build a batched fitness from an evaluator, a shared `[batch, obs_dim]`
451    /// observation tensor, and a per-genome `reducer` over the
452    /// `batch × action_dim` output slab (row-major).
453    pub fn new(
454        evaluator: E,
455        obs: Tensor<B, 2>,
456        reducer: impl Fn(&[f32]) -> f32 + Send + Sync + 'static,
457    ) -> Self {
458        Self {
459            evaluator,
460            obs,
461            reducer: Box::new(reducer),
462        }
463    }
464}
465
466impl<B: Backend, E> std::fmt::Debug for BatchGraphFitness<B, E> {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        f.debug_struct("BatchGraphFitness")
469            .field("obs", &self.obs)
470            .finish_non_exhaustive()
471    }
472}
473
474impl<B: Backend, E: BatchPhenotypeEvaluator<B>> GraphFitnessFn<B> for BatchGraphFitness<B, E> {
475    fn evaluate(
476        &self,
477        population: &[TopologyGenome],
478        _builder: &dyn PhenotypeBuilder<B>,
479        device: &<B as burn::tensor::backend::BackendTypes>::Device,
480    ) -> Vec<f32> {
481        if population.is_empty() {
482            return Vec::new();
483        }
484        let out = self
485            .evaluator
486            .evaluate_population(population, self.obs.clone(), device);
487        let [pop, batch, action] = out.dims();
488        let flat = out
489            .into_data()
490            .into_vec::<f32>()
491            .expect("output tensor must be readable as f32");
492        let slab = batch * action;
493        (0..pop)
494            .map(|p| (self.reducer)(&flat[p * slab..(p + 1) * slab]))
495            .collect()
496    }
497}
498
499// ---------------------------------------------------------------------------
500// Reproduction (private)
501// ---------------------------------------------------------------------------
502
503/// Read-only reproduction context shared across all species in one `ask`.
504struct ReproContext<'a> {
505    params: &'a NeatParams,
506    population: &'a [TopologyGenome],
507    fitness: &'a [f32],
508    species: &'a [Species],
509    registry: &'a InnovationRegistry,
510}
511
512/// The four independent RNG substreams used during reproduction.
513struct ReproRngs {
514    selection: StdRng,
515    crossover: StdRng,
516    mutation: StdRng,
517    misc: StdRng,
518}
519
520/// Append `count` offspring for `species_idx` to `out`.
521fn produce_offspring(
522    ctx: &ReproContext<'_>,
523    rngs: &mut ReproRngs,
524    species_idx: usize,
525    count: usize,
526    out: &mut Vec<TopologyGenome>,
527) {
528    if count == 0 {
529        return;
530    }
531    let sp = &ctx.species[species_idx];
532    let mut members = sp.members.clone();
533    // Sanitize NaN → −inf (worst) so it can never rank as best; descending.
534    let sane: Vec<f32> = ctx
535        .fitness
536        .iter()
537        .map(|&f| crate::fitness::sanitize_fitness(f))
538        .collect();
539    members.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
540
541    let mut produced = 0usize;
542    // Champion elitism: copy the best member unchanged (H5).
543    if members.len() > ctx.params.elitism_min_species_size {
544        out.push(ctx.population[members[0]].clone());
545        produced += 1;
546    }
547
548    let n_eligible = eligible_count(members.len(), ctx.params.survival_threshold);
549    let eligible = &members[..n_eligible];
550    while produced < count {
551        let mut child = make_child(ctx, rngs, eligible, species_idx);
552        apply_mutations(&mut child, ctx.params, ctx.registry, &mut rngs.mutation);
553        out.push(child);
554        produced += 1;
555    }
556}
557
558/// Number of top-fitness members eligible to reproduce (at least one).
559fn eligible_count(size: usize, survival_threshold: f32) -> usize {
560    // Casts: species sizes are small positive integers.
561    #[allow(
562        clippy::cast_precision_loss,
563        clippy::cast_possible_truncation,
564        clippy::cast_sign_loss
565    )]
566    let raw = (size as f32 * survival_threshold).ceil() as usize;
567    raw.max(1).min(size)
568}
569
570/// Produce one child: a mutation-only clone or an innovation-aligned crossover.
571fn make_child(
572    ctx: &ReproContext<'_>,
573    rngs: &mut ReproRngs,
574    eligible: &[usize],
575    species_idx: usize,
576) -> TopologyGenome {
577    if eligible.len() == 1 || rngs.misc.random::<f32>() < ctx.params.mutate_only_fraction {
578        let parent = eligible[rngs.selection.random_range(0..eligible.len())];
579        return ctx.population[parent].clone();
580    }
581    let pa = eligible[rngs.selection.random_range(0..eligible.len())];
582    let pb = if ctx.species.len() > 1
583        && rngs.misc.random::<f32>() < ctx.params.interspecies_mating_rate
584    {
585        let other = pick_other_species(ctx.species.len(), species_idx, &mut rngs.selection);
586        let other_members = &ctx.species[other].members;
587        other_members[rngs.selection.random_range(0..other_members.len())]
588    } else {
589        eligible[rngs.selection.random_range(0..eligible.len())]
590    };
591    crossover(
592        &ctx.population[pa],
593        ctx.fitness[pa],
594        &ctx.population[pb],
595        ctx.fitness[pb],
596        ctx.params,
597        &mut rngs.crossover,
598    )
599}
600
601/// Pick a species index different from `exclude` (caller guarantees `len > 1`).
602///
603/// Draws from the `len - 1` other indices and skips over `exclude`, so it
604/// consumes exactly one RNG value and always terminates (no rejection loop).
605fn pick_other_species(len: usize, exclude: usize, rng: &mut StdRng) -> usize {
606    let candidate = rng.random_range(0..len - 1);
607    if candidate >= exclude {
608        candidate + 1
609    } else {
610        candidate
611    }
612}
613
614/// Apply the four NEAT mutation operators in sequence, each gated by its rate.
615fn apply_mutations(
616    genome: &mut TopologyGenome,
617    params: &NeatParams,
618    registry: &InnovationRegistry,
619    rng: &mut StdRng,
620) {
621    if rng.random::<f32>() < params.p_mutate_weight {
622        mutate_weights(genome, params, rng);
623    }
624    if rng.random::<f32>() < params.p_add_connection {
625        mutate_add_connection(genome, params, registry, rng);
626    }
627    if rng.random::<f32>() < params.p_add_node {
628        mutate_add_node(genome, registry, rng);
629    }
630    if rng.random::<f32>() < params.p_toggle_enable {
631        mutate_toggle_enable(genome, rng);
632    }
633}
634
635// ---------------------------------------------------------------------------
636// Mutation operators (private)
637// ---------------------------------------------------------------------------
638
639/// Bounded attempts to find a valid (non-duplicate, non-cyclic) node pair for an
640/// add-connection mutation before giving up for this generation.
641const ADD_CONNECTION_ATTEMPTS: usize = 20;
642
643/// Perturb (or occasionally replace) every connection weight and every
644/// non-input node bias. Bias is, functionally, a weight, and mutating it is
645/// required for XOR.
646///
647/// # Panics
648///
649/// Panics if `weight_perturb_std` or `weight_init_std` is non-finite
650/// (`+∞` or `NaN`).
651fn mutate_weights(genome: &mut TopologyGenome, params: &NeatParams, rng: &mut StdRng) {
652    let perturb = Normal::new(0.0_f32, params.weight_perturb_std).unwrap_or_else(|err| {
653        panic!(
654            "weight_perturb_std must be finite, got {}: {err}",
655            params.weight_perturb_std
656        )
657    });
658    let replace = Normal::new(0.0_f32, params.weight_init_std).unwrap_or_else(|err| {
659        panic!(
660            "weight_init_std must be finite, got {}: {err}",
661            params.weight_init_std
662        )
663    });
664
665    for conn in &mut genome.connections {
666        if rng.random::<f32>() < params.p_weight_replace {
667            conn.weight = replace.sample(rng);
668        } else {
669            conn.weight += perturb.sample(rng);
670        }
671    }
672    for node in &mut genome.nodes {
673        if matches!(node.kind, NodeKind::Input) {
674            continue;
675        }
676        if rng.random::<f32>() < params.p_weight_replace {
677            node.bias = replace.sample(rng);
678        } else {
679            node.bias += perturb.sample(rng);
680        }
681    }
682}
683
684/// Add a connection between two currently-unconnected nodes, rejecting any edge
685/// that would create a cycle (feedforward invariant, H2). No-op if no valid pair
686/// is found within a bounded number of attempts.
687///
688/// # Panics
689///
690/// Panics if `weight_init_std` is non-finite (`+∞` or `NaN`).
691fn mutate_add_connection(
692    genome: &mut TopologyGenome,
693    params: &NeatParams,
694    registry: &InnovationRegistry,
695    rng: &mut StdRng,
696) {
697    let init = Normal::new(0.0_f32, params.weight_init_std).unwrap_or_else(|err| {
698        panic!(
699            "weight_init_std must be finite, got {}: {err}",
700            params.weight_init_std
701        )
702    });
703
704    // Sources may be any non-output node; targets any non-input node.
705    let sources: Vec<NodeId> = genome
706        .nodes
707        .iter()
708        .filter(|n| !matches!(n.kind, NodeKind::Output))
709        .map(|n| n.id)
710        .collect();
711    let targets: Vec<NodeId> = genome
712        .nodes
713        .iter()
714        .filter(|n| !matches!(n.kind, NodeKind::Input))
715        .map(|n| n.id)
716        .collect();
717    if sources.is_empty() || targets.is_empty() {
718        return;
719    }
720
721    for _ in 0..ADD_CONNECTION_ATTEMPTS {
722        let source = sources[rng.random_range(0..sources.len())];
723        let target = targets[rng.random_range(0..targets.len())];
724        if source == target || genome.is_connected(source, target) {
725            continue;
726        }
727        if genome.would_create_cycle(source, target) {
728            continue;
729        }
730        let innovation = registry.register_connection(source, target);
731        genome.insert_connection_sorted(ConnectionGene {
732            innovation,
733            source,
734            target,
735            weight: init.sample(rng),
736            enabled: true,
737        });
738        return;
739    }
740}
741
742/// Split a random enabled connection with a new hidden node: disable the old
743/// edge, add `source -> new` (weight `1.0`) and `new -> target` (the old
744/// weight). The unit incoming weight makes the split function-preserving at the
745/// instant of mutation, so a new node never disrupts behaviour before its
746/// weights are tuned.
747fn mutate_add_node(genome: &mut TopologyGenome, registry: &InnovationRegistry, rng: &mut StdRng) {
748    let enabled: Vec<usize> = genome
749        .connections
750        .iter()
751        .enumerate()
752        .filter(|(_, c)| c.enabled)
753        .map(|(i, _)| i)
754        .collect();
755    if enabled.is_empty() {
756        return;
757    }
758    let idx = enabled[rng.random_range(0..enabled.len())];
759    let split_innovation = genome.connections[idx].innovation;
760    let split = registry.register_node_split(split_innovation);
761
762    // Guard the toggle-re-enable edge case: if this split's node already
763    // materialized in this genome, re-adding it would duplicate the node.
764    if genome.node(split.new_node).is_some() {
765        return;
766    }
767
768    let (source, target, old_weight) = {
769        let conn = &mut genome.connections[idx];
770        conn.enabled = false;
771        (conn.source, conn.target, conn.weight)
772    };
773
774    genome.nodes.push(NodeGene {
775        id: split.new_node,
776        kind: NodeKind::Hidden,
777        activation: ActivationFn::Sigmoid,
778        bias: 0.0,
779    });
780    genome.insert_connection_sorted(ConnectionGene {
781        innovation: split.in_innov,
782        source,
783        target: split.new_node,
784        weight: 1.0,
785        enabled: true,
786    });
787    genome.insert_connection_sorted(ConnectionGene {
788        innovation: split.out_innov,
789        source: split.new_node,
790        target,
791        weight: old_weight,
792        enabled: true,
793    });
794}
795
796/// Flip the enabled bit of a random connection. Always cycle-safe: re-enabling
797/// an existing edge keeps the enabled subgraph a subset of the all-edges DAG.
798fn mutate_toggle_enable(genome: &mut TopologyGenome, rng: &mut StdRng) {
799    if genome.connections.is_empty() {
800        return;
801    }
802    let idx = rng.random_range(0..genome.connections.len());
803    genome.connections[idx].enabled = !genome.connections[idx].enabled;
804}
805
806// ---------------------------------------------------------------------------
807// Crossover (private)
808// ---------------------------------------------------------------------------
809
810/// Innovation-aligned crossover.
811///
812/// Matching genes are inherited from a random parent (disabled with
813/// `p_disable_inherited` if disabled in either); disjoint/excess genes from the
814/// fitter parent (from both when fitness is equal). Candidate edges are then
815/// filtered through a cycle check so a child that combines `A→B` and `B→A` from
816/// divergent parents stays feedforward (such drops are rare). Child nodes are the
817/// referenced endpoints (preferring the fitter parent) plus all input/output
818/// nodes.
819fn crossover(
820    p1: &TopologyGenome,
821    f1: f32,
822    p2: &TopologyGenome,
823    f2: f32,
824    params: &NeatParams,
825    rng: &mut StdRng,
826) -> TopologyGenome {
827    // Relative tolerance so "equal fitness" holds across magnitudes (XOR's
828    // 0..4 as well as a deferred consumer's large `−cost` scores).
829    let equal = (f1 - f2).abs() <= f32::EPSILON * f1.abs().max(f2.abs()).max(1.0);
830    let p1_fitter = f1 > f2;
831
832    let c1 = &p1.connections;
833    let c2 = &p2.connections;
834    let mut candidates: Vec<ConnectionGene> = Vec::with_capacity(c1.len().max(c2.len()));
835    let (mut i, mut j) = (0usize, 0usize);
836    while i < c1.len() && j < c2.len() {
837        match c1[i].innovation.cmp(&c2[j].innovation) {
838            std::cmp::Ordering::Equal => {
839                let mut gene = if rng.random::<bool>() {
840                    c1[i].clone()
841                } else {
842                    c2[j].clone()
843                };
844                let disabled_in_either = !c1[i].enabled || !c2[j].enabled;
845                let disable =
846                    disabled_in_either && rng.random::<f32>() < params.p_disable_inherited;
847                gene.enabled = !disable;
848                candidates.push(gene);
849                i += 1;
850                j += 1;
851            }
852            std::cmp::Ordering::Less => {
853                if equal || p1_fitter {
854                    candidates.push(c1[i].clone());
855                }
856                i += 1;
857            }
858            std::cmp::Ordering::Greater => {
859                if equal || !p1_fitter {
860                    candidates.push(c2[j].clone());
861                }
862                j += 1;
863            }
864        }
865    }
866    while i < c1.len() {
867        if equal || p1_fitter {
868            candidates.push(c1[i].clone());
869        }
870        i += 1;
871    }
872    while j < c2.len() {
873        if equal || !p1_fitter {
874            candidates.push(c2[j].clone());
875        }
876        j += 1;
877    }
878
879    // Cycle-safe assembly: keep candidates (innovation order) that do not close a
880    // cycle over the all-edges graph built so far.
881    let mut probe = TopologyGenome {
882        nodes: Vec::new(),
883        connections: Vec::new(),
884    };
885    for gene in candidates {
886        if probe.would_create_cycle(gene.source, gene.target) {
887            continue;
888        }
889        probe.connections.push(gene);
890    }
891    let child_conns = probe.connections;
892
893    // Node genes: prefer the fitter parent; always include all input/output nodes.
894    let (primary, secondary) = if p1_fitter || equal {
895        (p1, p2)
896    } else {
897        (p2, p1)
898    };
899    let mut node_map: HashMap<NodeId, NodeGene> = HashMap::new();
900    for node in &primary.nodes {
901        if matches!(
902            node.kind,
903            NodeKind::Input | NodeKind::Output | NodeKind::Bias
904        ) {
905            node_map.insert(node.id, node.clone());
906        }
907    }
908    let mut referenced: HashSet<NodeId> = HashSet::new();
909    for conn in &child_conns {
910        referenced.insert(conn.source);
911        referenced.insert(conn.target);
912    }
913    for id in referenced {
914        if node_map.contains_key(&id) {
915            continue;
916        }
917        if let Some(node) = primary.node(id).or_else(|| secondary.node(id)) {
918            node_map.insert(id, node.clone());
919        }
920    }
921    let mut nodes: Vec<NodeGene> = node_map.into_values().collect();
922    nodes.sort_by_key(|n| n.id);
923
924    TopologyGenome {
925        nodes,
926        connections: child_conns,
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::neuroevolution::topology::InnovationId;
934    use rand::SeedableRng;
935
936    fn node(id: u64, kind: NodeKind) -> NodeGene {
937        NodeGene {
938            id: NodeId::new(id),
939            kind,
940            activation: ActivationFn::Sigmoid,
941            bias: 0.0,
942        }
943    }
944
945    fn conn(innovation: u64, source: u64, target: u64) -> ConnectionGene {
946        ConnectionGene {
947            innovation: InnovationId::new(innovation),
948            source: NodeId::new(source),
949            target: NodeId::new(target),
950            weight: 0.5,
951            enabled: true,
952        }
953    }
954
955    /// Replaying a fixed mutation script twice (fresh registry + same seed)
956    /// yields identical innovation AND node id sequences.
957    #[test]
958    fn test_innovation_numbering_is_deterministic() {
959        fn replay() -> (Vec<u64>, Vec<NodeId>) {
960            let registry = InnovationRegistry::new(3, 2);
961            let mut rng = StdRng::seed_from_u64(99);
962            let params = NeatParams::default_for(10, 2, 1);
963            let mut g = TopologyGenome::minimal(2, 1, &registry, &mut rng, 1.0);
964            mutate_add_node(&mut g, &registry, &mut rng);
965            mutate_add_connection(&mut g, &params, &registry, &mut rng);
966            mutate_add_node(&mut g, &registry, &mut rng);
967            mutate_weights(&mut g, &params, &mut rng);
968            mutate_add_connection(&mut g, &params, &registry, &mut rng);
969            let innovs: Vec<u64> = g.connections.iter().map(|c| c.innovation.get()).collect();
970            let mut nodes: Vec<NodeId> = g.nodes.iter().map(|n| n.id).collect();
971            nodes.sort_unstable();
972            (innovs, nodes)
973        }
974        let (i1, n1) = replay();
975        let (i2, n2) = replay();
976        assert_eq!(i1, i2, "innovation id sequence must be reproducible");
977        assert_eq!(n1, n2, "node id sequence must be reproducible");
978    }
979
980    /// Crossover classifies matching / disjoint / excess and inherits
981    /// disjoint+excess from the fitter parent only.
982    #[test]
983    fn test_crossover_inherits_disjoint_excess_from_fitter() {
984        // Nodes 0,1 inputs; 2 output; 3 hidden. p1 fitter.
985        let nodes_p1 = vec![
986            node(0, NodeKind::Input),
987            node(1, NodeKind::Input),
988            node(2, NodeKind::Output),
989            node(3, NodeKind::Hidden),
990        ];
991        // p1 conns: 0->2 (i0), 1->2 (i1), 0->3 (i2), 3->2 (i3).
992        let p1 = TopologyGenome::new(
993            nodes_p1,
994            vec![conn(0, 0, 2), conn(1, 1, 2), conn(2, 0, 3), conn(3, 3, 2)],
995        );
996        // p2 (less fit) conns: 0->2 (i0), 1->2 (i1), 1->4 (i4) with hidden 4.
997        let nodes_p2 = vec![
998            node(0, NodeKind::Input),
999            node(1, NodeKind::Input),
1000            node(2, NodeKind::Output),
1001            node(4, NodeKind::Hidden),
1002        ];
1003        let p2 = TopologyGenome::new(nodes_p2, vec![conn(0, 0, 2), conn(1, 1, 2), conn(4, 1, 4)]);
1004
1005        let params = NeatParams::default_for(10, 2, 1);
1006        let mut rng = StdRng::seed_from_u64(3);
1007        let child = crossover(&p1, 2.0, &p2, 1.0, &params, &mut rng);
1008
1009        let innovs: Vec<u64> = child
1010            .connections
1011            .iter()
1012            .map(|c| c.innovation.get())
1013            .collect();
1014        assert_eq!(
1015            innovs,
1016            vec![0, 1, 2, 3],
1017            "matching (0,1) + fitter disjoint/excess (2,3); less-fit excess (4) dropped"
1018        );
1019        assert!(
1020            child.is_innovation_sorted(),
1021            "child stays innovation-sorted"
1022        );
1023        assert!(
1024            child.node(NodeId::new(4)).is_none(),
1025            "node only reachable via dropped gene is excluded"
1026        );
1027        assert!(
1028            child.node(NodeId::new(3)).is_some(),
1029            "hidden node from inherited gene is kept"
1030        );
1031    }
1032
1033    /// Equal fitness inherits disjoint/excess from both parents.
1034    #[test]
1035    fn test_crossover_equal_fitness_takes_from_both() {
1036        let nodes_p1 = vec![
1037            node(0, NodeKind::Input),
1038            node(1, NodeKind::Input),
1039            node(2, NodeKind::Output),
1040        ];
1041        let p1 = TopologyGenome::new(nodes_p1.clone(), vec![conn(0, 0, 2), conn(2, 1, 2)]);
1042        let p2 = TopologyGenome::new(nodes_p1, vec![conn(0, 0, 2), conn(3, 1, 2)]);
1043        let params = NeatParams::default_for(10, 2, 1);
1044        let mut rng = StdRng::seed_from_u64(5);
1045        let child = crossover(&p1, 1.0, &p2, 1.0, &params, &mut rng);
1046        let innovs: Vec<u64> = child
1047            .connections
1048            .iter()
1049            .map(|c| c.innovation.get())
1050            .collect();
1051        assert_eq!(
1052            innovs,
1053            vec![0, 2, 3],
1054            "equal fitness keeps disjoint/excess from both"
1055        );
1056    }
1057
1058    /// Add-connection never closes a cycle: in a fully-connected feedforward
1059    /// triangle whose only remaining pair is a back-edge, nothing is added.
1060    #[test]
1061    fn test_add_connection_rejects_cycles() {
1062        // Feedforward: 0(in) -> 2(h) -> 3(h) -> 1(out), fully forward-connected.
1063        let nodes = vec![
1064            node(0, NodeKind::Input),
1065            node(2, NodeKind::Hidden),
1066            node(3, NodeKind::Hidden),
1067            node(1, NodeKind::Output),
1068        ];
1069        let conns = vec![
1070            conn(0, 0, 2),
1071            conn(1, 0, 3),
1072            conn(2, 0, 1),
1073            conn(3, 2, 3),
1074            conn(4, 2, 1),
1075            conn(5, 3, 1),
1076        ];
1077        let mut g = TopologyGenome::new(nodes, conns);
1078        let before = g.connections.len();
1079        let registry = InnovationRegistry::new(4, 6);
1080        let params = NeatParams::default_for(10, 1, 1);
1081        let mut rng = StdRng::seed_from_u64(7);
1082        // The only non-duplicate, non-self forward-source/target pair left is the
1083        // back-edge 3->2, which would cycle; it must never be added.
1084        for _ in 0..50 {
1085            mutate_add_connection(&mut g, &params, &registry, &mut rng);
1086        }
1087        assert_eq!(g.connections.len(), before, "no cyclic edge is ever added");
1088        assert!(
1089            !g.is_connected(NodeId::new(3), NodeId::new(2)),
1090            "the back-edge 3->2 is rejected"
1091        );
1092    }
1093
1094    /// Add-node splits an enabled connection: disables it, inserts one hidden
1095    /// node, and adds the two replacement edges (`source → new` weight `1.0`,
1096    /// `new → target` old weight), staying innovation-sorted.
1097    #[test]
1098    fn test_add_node_splits_connection() {
1099        let registry = InnovationRegistry::new(3, 2);
1100        let mut rng = StdRng::seed_from_u64(1);
1101        let mut g = TopologyGenome::minimal(2, 1, &registry, &mut rng, 1.0);
1102        let nodes_before = g.nodes.len();
1103        let conns_before = g.connections.len();
1104
1105        mutate_add_node(&mut g, &registry, &mut rng);
1106
1107        assert_eq!(g.nodes.len(), nodes_before + 1, "one hidden node inserted");
1108        assert_eq!(
1109            g.connections.len(),
1110            conns_before + 2,
1111            "split adds two connections"
1112        );
1113        assert_eq!(
1114            g.connections.iter().filter(|c| !c.enabled).count(),
1115            1,
1116            "the split connection is disabled"
1117        );
1118        let hidden: Vec<&NodeGene> = g
1119            .nodes
1120            .iter()
1121            .filter(|n| matches!(n.kind, NodeKind::Hidden))
1122            .collect();
1123        assert_eq!(hidden.len(), 1);
1124        assert_eq!(
1125            hidden[0].id.get(),
1126            3,
1127            "new hidden node id follows the seed nodes"
1128        );
1129        assert!(
1130            g.is_innovation_sorted(),
1131            "connections stay innovation-sorted"
1132        );
1133        assert!(
1134            g.connections
1135                .iter()
1136                .any(|c| c.target == NodeId::new(3) && c.enabled && (c.weight - 1.0).abs() < 1e-6),
1137            "the source -> new edge has the function-preserving weight 1.0"
1138        );
1139    }
1140
1141    /// Toggle flips exactly one connection's enabled bit.
1142    #[test]
1143    fn test_toggle_enable_flips_one_connection() {
1144        let registry = InnovationRegistry::new(3, 2);
1145        let mut rng = StdRng::seed_from_u64(2);
1146        let mut g = TopologyGenome::minimal(2, 1, &registry, &mut rng, 1.0);
1147        let before: Vec<bool> = g.connections.iter().map(|c| c.enabled).collect();
1148        mutate_toggle_enable(&mut g, &mut rng);
1149        let after: Vec<bool> = g.connections.iter().map(|c| c.enabled).collect();
1150        let flipped = before.iter().zip(&after).filter(|(a, b)| a != b).count();
1151        assert_eq!(flipped, 1, "exactly one connection's enabled bit flips");
1152    }
1153
1154    /// The add-node guard: if a connection's split node already exists in the
1155    /// genome (e.g. the split edge was re-enabled by a toggle), splitting it
1156    /// again must not duplicate the node or its edges.
1157    #[test]
1158    fn test_add_node_guard_prevents_duplicate_node() {
1159        let registry = InnovationRegistry::new(3, 2);
1160        let split = registry.register_node_split(InnovationId::new(0)); // allocates node 3
1161        // Genome already holds node 3; only innovation 0 (its split edge) is
1162        // enabled, so add-node is forced to re-select it.
1163        let nodes = vec![
1164            node(0, NodeKind::Input),
1165            node(1, NodeKind::Input),
1166            node(2, NodeKind::Output),
1167            node(3, NodeKind::Hidden),
1168        ];
1169        let conns = vec![
1170            ConnectionGene {
1171                innovation: InnovationId::new(0),
1172                source: NodeId::new(0),
1173                target: NodeId::new(2),
1174                weight: 0.5,
1175                enabled: true,
1176            },
1177            ConnectionGene {
1178                innovation: split.in_innov,
1179                source: NodeId::new(0),
1180                target: NodeId::new(3),
1181                weight: 1.0,
1182                enabled: false,
1183            },
1184            ConnectionGene {
1185                innovation: split.out_innov,
1186                source: NodeId::new(3),
1187                target: NodeId::new(2),
1188                weight: 0.5,
1189                enabled: false,
1190            },
1191        ];
1192        let mut g = TopologyGenome::new(nodes, conns);
1193        let nodes_before = g.nodes.len();
1194        let conns_before = g.connections.len();
1195        let mut rng = StdRng::seed_from_u64(4);
1196
1197        mutate_add_node(&mut g, &registry, &mut rng);
1198
1199        assert_eq!(
1200            g.nodes.len(),
1201            nodes_before,
1202            "guard prevents a duplicate split node"
1203        );
1204        assert_eq!(
1205            g.connections.len(),
1206            conns_before,
1207            "no duplicate split edges added"
1208        );
1209    }
1210
1211    /// `ask`/`tell` run a few generations, preserving `pop_size` and tracking the
1212    /// best. A trivial maximization fitness rewards more enabled connections.
1213    #[test]
1214    fn test_ask_tell_smoke_preserves_pop_size_and_tracks_best() {
1215        use burn::backend::Flex;
1216        type TestBackend = Flex;
1217
1218        struct ConnCountFitness;
1219        impl GraphFitnessFn<TestBackend> for ConnCountFitness {
1220            fn evaluate(
1221                &self,
1222                population: &[TopologyGenome],
1223                _builder: &dyn PhenotypeBuilder<TestBackend>,
1224                _device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
1225            ) -> Vec<f32> {
1226                // Cast: enabled-connection counts are tiny in this smoke test.
1227                #[allow(clippy::cast_precision_loss)]
1228                population
1229                    .iter()
1230                    .map(|g| g.connections.iter().filter(|c| c.enabled).count() as f32)
1231                    .collect()
1232            }
1233        }
1234
1235        let device = Default::default();
1236        let mut params = NeatParams::default_for(24, 2, 1);
1237        params.p_add_node = 0.3;
1238        params.p_add_connection = 0.5;
1239
1240        let strat = NeatStrategy::<TestBackend>::new();
1241        let mut rng = StdRng::seed_from_u64(11);
1242        let mut state = strat.init(&params, &mut rng, &device);
1243        let builder = crate::neuroevolution::phenotype::InterpretedBuilder;
1244        let fitness_fn = ConnCountFitness;
1245
1246        for _ in 0..8 {
1247            let (population, next) = strat.ask(&params, &state, &mut rng);
1248            assert_eq!(
1249                population.len(),
1250                params.pop_size,
1251                "ask returns pop_size genomes"
1252            );
1253            let fitness = fitness_fn.evaluate(&population, &builder, &device);
1254            state = strat.tell(&params, population, fitness, next, &mut rng);
1255            assert!(
1256                !state.species.is_empty(),
1257                "speciation always yields >= 1 species"
1258            );
1259        }
1260        assert_eq!(state.generation, 8);
1261        let (_, best) = strat.best(&state).expect("best exists after tell");
1262        assert!(best >= 2.0, "best rewards enabled connections; got {best}");
1263    }
1264
1265    /// Regression (ADR 0034, issue #133): `tell` is NEAT's driver chokepoint and
1266    /// must sanitize non-finite fitness before storing/speciating. A `NaN` member
1267    /// must never become the champion nor poison per-species bookkeeping, and a
1268    /// `+∞` member must rank top but as a **finite** value (`f32::MAX`).
1269    #[test]
1270    fn test_tell_sanitizes_nan_and_inf_fitness() {
1271        use burn::backend::Flex;
1272        type TestBackend = Flex;
1273
1274        let device = Default::default();
1275        let params = NeatParams::default_for(4, 2, 1);
1276        let strat = NeatStrategy::<TestBackend>::new();
1277        let mut rng = StdRng::seed_from_u64(7);
1278        let state = strat.init(&params, &mut rng, &device);
1279
1280        let (population, next) = strat.ask(&params, &state, &mut rng);
1281        let n = population.len();
1282        // Member 0 is NaN (must NOT champion), member 1 is +∞ (top but finite),
1283        // the rest are a plain finite value.
1284        let mut fitness: Vec<f32> = vec![1.0_f32; n];
1285        fitness[0] = f32::NAN;
1286        fitness[1] = f32::INFINITY;
1287        let state = strat.tell(&params, population, fitness, next, &mut rng);
1288
1289        // Stored fitness is sanitized: none is NaN; NaN → −∞, +∞ → f32::MAX.
1290        assert!(
1291            state.fitness.iter().all(|f| !f.is_nan()),
1292            "no stored fitness is NaN after the tell chokepoint"
1293        );
1294        assert!(
1295            state.fitness[0].is_infinite() && state.fitness[0].is_sign_negative(),
1296            "the NaN member is sanitized to the maximise-space worst sentinel (−∞)"
1297        );
1298        approx::assert_relative_eq!(state.fitness[1], f32::MAX);
1299
1300        // The champion is the sanitized +∞ = f32::MAX: ranks top but is finite,
1301        // and is never the NaN member.
1302        let (_, best) = strat.best(&state).expect("best exists after tell");
1303        assert!(
1304            best.is_finite(),
1305            "champion fitness is finite (never NaN/±∞); got {best}"
1306        );
1307        approx::assert_relative_eq!(best, f32::MAX);
1308
1309        // No species' best or size-adjusted mean is NaN-poisoned.
1310        for s in &state.species {
1311            assert!(!s.best_fitness.is_nan(), "species best_fitness is not NaN");
1312            assert!(
1313                !s.adjusted_fitness_sum.is_nan(),
1314                "a NaN member never poisons adjusted_fitness_sum"
1315            );
1316        }
1317    }
1318}