Skip to main content

rlevo_evolution/algorithms/
gp_cgp.rs

1//! Cartesian Genetic Programming.
2//!
3//! CGP encodes a directed acyclic computation graph on a fixed
4//! `rows × cols` grid. Each node stores `(function_id, input_0, input_1)`,
5//! plus the final output gene picks which node produces the output.
6//! The genotype is a fixed-length integer vector, so populations are
7//! `Tensor<B, 2, Int>` and fit the tensor abstraction cleanly.
8//!
9//! # Evolutionary engine
10//!
11//! Canonical CGP uses a `(1 + λ)` Evolution Strategy with point
12//! mutation and no crossover. This module re-implements just that
13//! engine directly — not via [`crate::algorithms::es_classical`] — so
14//! the mutation logic can be specialized to the CGP genome semantics
15//! (constrained feed-forward connections, `function_id` range, …).
16//!
17//! # Function set
18//!
19//! The v1 function set is the shared [`ArithmeticFunctionSet`]:
20//!
21//! | id | op | arity | formula |
22//! |---|---|---|---|
23//! | 0 | add | 2 | `a + b` |
24//! | 1 | sub | 2 | `a − b` |
25//! | 2 | mul | 2 | `a · b` |
26//! | 3 | `protected_div` | 2 | `a / b` (or `a` if `|b| < ε`) |
27//! | 4 | sin | 1 | `sin(a)` |
28//! | 5 | cos | 1 | `cos(a)` |
29//! | 6 | tanh | 1 | `tanh(a)` |
30//! | 7 | const 1.0 | 0 | `1.0` |
31//!
32//! Opcode evaluation is delegated to the [`FunctionSet`] trait: [`evaluate_cgp`]
33//! is a thin wrapper over the generic [`evaluate_cgp_with`], which threads a
34//! concrete `&F` through the per-node loop so the opcode dispatch inlines. The
35//! [`FUNCTION_ARITIES`] / [`NUM_FUNCTIONS`] constants are retained for the
36//! mutation logic, which samples function ids in `0..NUM_FUNCTIONS`.
37//!
38//! # Phenotype evaluation
39//!
40//! Evaluation runs on the host because the per-node dispatch is not a
41//! good fit for dense tensor ops; node values are computed in
42//! topological order (left-to-right across the grid columns).
43//! Genotype storage stays on-device to match the other strategies.
44//!
45//! # Reference
46//!
47//! - Miller (2011), *Cartesian Genetic Programming* (Natural Computing
48//!   Series).
49
50use std::marker::PhantomData;
51
52use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
53use rand::{Rng, RngExt};
54
55use rlevo_core::config::{self, ConfigError, Validate};
56use rlevo_core::probability::Probability;
57
58use crate::function_set::{ArithmeticFunctionSet, FunctionSet, Symbol};
59use crate::rng::{SeedPurpose, seed_stream};
60use crate::strategy::{Strategy, StrategyMetrics};
61
62/// Fixed v1 function set: arity of each opcode.
63pub const FUNCTION_ARITIES: [usize; 8] = [2, 2, 2, 2, 1, 1, 1, 0];
64/// Number of opcodes in the v1 function set.
65pub const NUM_FUNCTIONS: usize = FUNCTION_ARITIES.len();
66
67/// Static configuration for a [`CartesianGeneticProgramming`] run.
68#[derive(Debug, Clone)]
69pub struct CgpConfig {
70    /// Number of offspring per generation (λ in `(1 + λ)`).
71    pub lambda: usize,
72    /// Number of inputs (independent variables) the program sees.
73    pub n_inputs: usize,
74    /// Number of grid rows.
75    pub rows: usize,
76    /// Number of grid columns.
77    pub cols: usize,
78    /// Mutation rate applied to each gene of the integer genome. Valid by
79    /// construction (`[0, 1]`).
80    pub mutation_rate: Probability,
81    /// Levels-back parameter: how many previous columns a node can
82    /// connect to. `usize::MAX` means "any previous column".
83    pub levels_back: usize,
84}
85
86impl CgpConfig {
87    /// Sensible defaults: 1-output, 1-row, 30-column grid, mutation
88    /// rate tuned to flip ~3 genes per genome.
89    #[must_use]
90    pub fn default_for(n_inputs: usize) -> Self {
91        let rows = 1;
92        let cols = 30;
93        let genes_per_node = 3; // (function, input_0, input_1)
94        let output_genes = 1;
95        let total_genes = rows * cols * genes_per_node + output_genes;
96        #[allow(clippy::cast_precision_loss)]
97        let mutation_rate = Probability::new(3.0 / total_genes as f32);
98        Self {
99            lambda: 4,
100            n_inputs,
101            rows,
102            cols,
103            mutation_rate,
104            levels_back: usize::MAX,
105        }
106    }
107
108    /// Genes per node in the genotype layout: `(function_id, input_0, input_1)`.
109    pub const GENES_PER_NODE: usize = 3;
110    /// Number of output genes per program (one index pointing to the node
111    /// whose value is taken as the program output).
112    pub const OUTPUT_GENES: usize = 1;
113
114    /// Total genome length (nodes × 3 + outputs).
115    #[must_use]
116    pub fn genome_len(&self) -> usize {
117        self.rows * self.cols * Self::GENES_PER_NODE + Self::OUTPUT_GENES
118    }
119}
120
121impl Validate for CgpConfig {
122    fn validate(&self) -> Result<(), ConfigError> {
123        const C: &str = "CgpConfig";
124        config::at_least(C, "lambda", self.lambda, 1)?;
125        config::at_least(C, "n_inputs", self.n_inputs, 1)?;
126        config::at_least(C, "rows", self.rows, 1)?;
127        config::at_least(C, "cols", self.cols, 1)?;
128        // `mutation_rate` is a `Probability`: valid by construction, so no
129        // `in_range` check here — see ADR 0031.
130        config::at_least(C, "levels_back", self.levels_back, 1)?;
131        Ok(())
132    }
133}
134
135/// Generation state for [`CartesianGeneticProgramming`].
136#[derive(Debug, Clone)]
137pub struct CgpState<B: Backend> {
138    /// Parent genotype, shape `(1, genome_len)`.
139    pub parent: Tensor<B, 2, Int>,
140    /// Parent fitness (host-side scalar cache).
141    ///
142    /// `None` until the first `tell` bootstraps it. Using `Option` — rather
143    /// than a `f32::NEG_INFINITY` "unset" sentinel — keeps "uninitialised"
144    /// distinct from a legitimately sanitized `−∞` fitness. A `Minimize`
145    /// landscape whose natural cost is `+∞` canonicalizes to `−∞` (ADR 0034);
146    /// with the old sentinel that value re-triggered the bootstrap branch on
147    /// the next `ask`, collapsing the `(1+λ)` loop to a single parent forever.
148    pub parent_fitness: Option<f32>,
149    /// Best-so-far genotype.
150    pub best_genome: Option<Tensor<B, 2, Int>>,
151    /// Best-so-far fitness.
152    pub best_fitness: f32,
153    /// Generation counter.
154    pub generation: usize,
155}
156
157/// Classical Cartesian GP with `(1 + λ)` ES.
158///
159/// # Example
160///
161/// ```no_run
162/// use burn::backend::Flex;
163/// use rlevo_evolution::algorithms::gp_cgp::{CartesianGeneticProgramming, CgpConfig};
164///
165/// let strategy = CartesianGeneticProgramming::<Flex>::new();
166/// let params = CgpConfig::default_for(1);
167/// assert!(params.genome_len() > 0);
168/// let _ = strategy;
169/// ```
170#[derive(Debug, Clone, Copy, Default)]
171pub struct CartesianGeneticProgramming<B: Backend> {
172    _backend: PhantomData<fn() -> B>,
173}
174
175impl<B: Backend> CartesianGeneticProgramming<B> {
176    /// Builds a new (stateless) strategy object.
177    #[must_use]
178    pub fn new() -> Self {
179        Self {
180            _backend: PhantomData,
181        }
182    }
183
184    fn sample_initial_genome(params: &CgpConfig, rng: &mut dyn Rng) -> Vec<i64> {
185        let mut genome = Vec::with_capacity(params.genome_len());
186        for col in 0..params.cols {
187            for _row in 0..params.rows {
188                #[allow(clippy::cast_possible_wrap)]
189                let func = rng.random_range(0..NUM_FUNCTIONS as i64);
190                let (inp0, inp1) = sample_input_pair(col, params, rng);
191                genome.push(func);
192                genome.push(inp0);
193                genome.push(inp1);
194            }
195        }
196        // Output gene: any node index or input index.
197        let max_node_idx = params.n_inputs + params.rows * params.cols;
198        #[allow(clippy::cast_possible_wrap)]
199        genome.push(rng.random_range(0..max_node_idx as i64));
200        genome
201    }
202
203    fn genome_to_host(genome: &Tensor<B, 2, Int>) -> Vec<i64> {
204        genome
205            .clone()
206            .into_data()
207            .into_vec::<i32>()
208            .expect("genome tensor must be readable as i32")
209            .into_iter()
210            .map(i64::from)
211            .collect()
212    }
213}
214
215fn sample_input_pair(col: usize, params: &CgpConfig, rng: &mut dyn Rng) -> (i64, i64) {
216    let min_col = col.saturating_sub(params.levels_back);
217    let node_indices_start = params.n_inputs + min_col * params.rows;
218    let node_indices_end = params.n_inputs + col * params.rows;
219    let max = node_indices_end.max(params.n_inputs);
220    // Allowed inputs: 0..n_inputs (graph inputs) ∪ previous nodes.
221    let input_count = params.n_inputs
222        + (max - params.n_inputs)
223            .saturating_sub(node_indices_start.saturating_sub(params.n_inputs));
224    let pool: Vec<i64> = (0..params.n_inputs)
225        .chain(node_indices_start..node_indices_end)
226        .map(|i| {
227            #[allow(clippy::cast_possible_wrap)]
228            let v = i as i64;
229            v
230        })
231        .collect();
232    let pool = if pool.is_empty() {
233        #[allow(clippy::cast_possible_wrap)]
234        (0..params.n_inputs as i64).collect()
235    } else {
236        pool
237    };
238    let _ = input_count;
239    if pool.is_empty() {
240        // No legal input candidates (only reachable when n_inputs == 0, which
241        // CgpConfig::validate rejects). Defensive: emit a benign (0, 0) so the
242        // primitive is total for direct callers that bypass the validating harness.
243        return (0, 0);
244    }
245    let pick = |rng: &mut dyn Rng| -> i64 {
246        let idx = rng.random_range(0..pool.len());
247        pool[idx]
248    };
249    (pick(rng), pick(rng))
250}
251
252fn mutate_genome(genome: &mut [i64], params: &CgpConfig, rng: &mut dyn Rng) {
253    let genes_per_node = CgpConfig::GENES_PER_NODE;
254    let node_genes = params.rows * params.cols * genes_per_node;
255    for (gene_idx, gene) in genome.iter_mut().enumerate() {
256        if rng.random::<f32>() >= params.mutation_rate.get() {
257            continue;
258        }
259        if gene_idx < node_genes {
260            let within = gene_idx % genes_per_node;
261            let node_idx = gene_idx / genes_per_node;
262            let col = node_idx / params.rows;
263            if within == 0 {
264                // function
265                #[allow(clippy::cast_possible_wrap)]
266                {
267                    *gene = rng.random_range(0..NUM_FUNCTIONS as i64);
268                }
269            } else {
270                let (new0, new1) = sample_input_pair(col, params, rng);
271                *gene = if within == 1 { new0 } else { new1 };
272            }
273        } else {
274            // output gene
275            let max_node_idx = params.n_inputs + params.rows * params.cols;
276            #[allow(clippy::cast_possible_wrap)]
277            {
278                *gene = rng.random_range(0..max_node_idx as i64);
279            }
280        }
281    }
282}
283
284/// Evaluates a CGP genotype at a set of input rows.
285///
286/// `genome` is the host-side integer genotype (length `params.genome_len()`).
287/// `inputs` is a slice of `n_samples` rows, each of length `params.n_inputs`.
288/// Returns one `f32` output per input row.
289///
290/// Out-of-range input/node indices in the genome are clamped to the
291/// last buffer slot rather than panicking — this keeps fitness
292/// evaluation robust to mutated-but-unrepaired genotypes. Non-finite
293/// node values (e.g., `inf` from divisions or `tan`) collapse to `0.0`.
294///
295/// # Panics
296///
297/// Panics if `genome` is empty (the last gene is the output index).
298#[must_use]
299pub fn evaluate_cgp(genome: &[i64], params: &CgpConfig, inputs: &[Vec<f32>]) -> Vec<f32> {
300    evaluate_cgp_with(genome, params, inputs, &ArithmeticFunctionSet)
301}
302
303/// Evaluates a CGP genotype against an arbitrary [`FunctionSet`].
304///
305/// This is the generic core of [`evaluate_cgp`]: the opcode at each node is
306/// dispatched through `fs.apply` rather than a hard-coded match, so the same
307/// CGP engine can run any function set. [`evaluate_cgp`] calls this with the
308/// default [`ArithmeticFunctionSet`].
309///
310/// `fs` is taken as a concrete monomorphized `&F` (never `&dyn FunctionSet`)
311/// so the `apply` dispatch inlines in the per-node × per-sample inner loop.
312///
313/// Each node supplies up to two argument slots (`input_0`, `input_1`). The
314/// opcode's [`arity`](FunctionSet::arity) selects how many of them reach
315/// `apply`: arity-2 ops receive both, arity-1 ops receive only the first, and
316/// zero-arity ops (constants) receive an empty slice. Out-of-range
317/// input/node/opcode ids are clamped or treated as inert (arity 0) rather than
318/// panicking, keeping evaluation robust to mutated-but-unrepaired genotypes.
319/// Non-finite node values collapse to `0.0`.
320///
321/// # Panics
322///
323/// Panics if `genome` is empty (the last gene is the output index).
324#[must_use]
325pub fn evaluate_cgp_with<F: FunctionSet>(
326    genome: &[i64],
327    params: &CgpConfig,
328    inputs: &[Vec<f32>],
329    fs: &F,
330) -> Vec<f32> {
331    let node_count = params.rows * params.cols;
332    let n_inputs = params.n_inputs;
333    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
334    let output_idx = genome[genome.len() - 1] as usize;
335
336    let mut outputs = Vec::with_capacity(inputs.len());
337    let mut buf = vec![0.0_f32; n_inputs + node_count];
338
339    for sample in inputs {
340        for (i, v) in sample.iter().enumerate() {
341            buf[i] = *v;
342        }
343        for node in 0..node_count {
344            let base = node * 3;
345            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
346            let func = genome[base] as i32;
347            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
348            let a_idx = genome[base + 1] as usize;
349            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
350            let b_idx = genome[base + 2] as usize;
351            let a = buf[a_idx.min(buf.len() - 1)];
352            let b = buf[b_idx.min(buf.len() - 1)];
353            let sym = Symbol::from_raw(func);
354            let arity = fs.arity(sym);
355            let arg_buf = [a, b];
356            let v = fs.apply(sym, &arg_buf[..arity.min(arg_buf.len())]);
357            buf[n_inputs + node] = crate::function_set::finite_or_zero(v);
358        }
359        outputs.push(buf[output_idx.min(buf.len() - 1)]);
360    }
361
362    outputs
363}
364
365impl<B: Backend> Strategy<B> for CartesianGeneticProgramming<B>
366where
367    B::Device: Clone,
368{
369    type Params = CgpConfig;
370    type State = CgpState<B>;
371    type Genome = Tensor<B, 2, Int>;
372
373    /// Samples the initial parent genome by drawing random node functions and
374    /// feed-forward input connections via `rng`, then uploads the genotype as
375    /// a `(1, genome_len)` integer tensor.
376    fn init(
377        &self,
378        params: &CgpConfig,
379        rng: &mut dyn Rng,
380        device: &<B as burn::tensor::backend::BackendTypes>::Device,
381    ) -> CgpState<B> {
382        debug_assert!(
383            params.validate().is_ok(),
384            "invalid CgpConfig reached init: {params:?}"
385        );
386        let genome_vec = Self::sample_initial_genome(params, rng);
387        let parent = Tensor::<B, 2, Int>::from_data(
388            TensorData::new(genome_vec, [1, params.genome_len()]),
389            device,
390        );
391        CgpState {
392            parent,
393            parent_fitness: None,
394            best_genome: None,
395            best_fitness: f32::NEG_INFINITY,
396            generation: 0,
397        }
398    }
399
400    /// Returns the offspring population for the current generation.
401    ///
402    /// On the first call (parent fitness not yet set), returns the single
403    /// parent genome unchanged for initial fitness evaluation.
404    /// On subsequent calls, produces `params.lambda` children by cloning the
405    /// parent and applying per-gene point mutation, with mutation draws taken
406    /// from a deterministic `seed_stream` (host-RNG convention).
407    fn ask(
408        &self,
409        params: &CgpConfig,
410        state: &CgpState<B>,
411        rng: &mut dyn Rng,
412        device: &<B as burn::tensor::backend::BackendTypes>::Device,
413    ) -> (Tensor<B, 2, Int>, CgpState<B>) {
414        // First call: evaluate the parent as "offspring" of size 1.
415        if state.parent_fitness.is_none() {
416            return (state.parent.clone(), state.clone());
417        }
418
419        let mut mut_rng = seed_stream(
420            rng.next_u64(),
421            state.generation as u64,
422            SeedPurpose::Mutation,
423        );
424        let parent_vec = Self::genome_to_host(&state.parent);
425        let mut offspring_genomes: Vec<i64> =
426            Vec::with_capacity(params.lambda * params.genome_len());
427        for _ in 0..params.lambda {
428            let mut child = parent_vec.clone();
429            mutate_genome(&mut child, params, &mut mut_rng);
430            offspring_genomes.extend(child);
431        }
432        #[allow(clippy::cast_possible_truncation)]
433        let offspring_genomes_i32: Vec<i32> =
434            offspring_genomes.into_iter().map(|v| v as i32).collect();
435        let offspring = Tensor::<B, 2, Int>::from_data(
436            TensorData::new(offspring_genomes_i32, [params.lambda, params.genome_len()]),
437            device,
438        );
439        (offspring, state.clone())
440    }
441
442    /// Applies `(1+λ)` selection and returns the updated state.
443    ///
444    /// The canonical CGP tie-breaking rule is used: an offspring replaces the
445    /// parent when its fitness is **less than or equal to** the parent's,
446    /// allowing neutral mutations to accumulate and maintain genetic diversity
447    /// in the inactive (non-coding) portion of the genome.
448    ///
449    /// The first `tell` after `init` bootstraps the parent fitness from the
450    /// initial single-genome evaluation rather than running selection.
451    fn tell(
452        &self,
453        _params: &CgpConfig,
454        offspring: Tensor<B, 2, Int>,
455        fitness: Tensor<B, 1>,
456        mut state: CgpState<B>,
457        _rng: &mut dyn Rng,
458    ) -> (CgpState<B>, StrategyMetrics) {
459        let fitness_host = fitness
460            .into_data()
461            .into_vec::<f32>()
462            .expect("fitness tensor must be readable as f32");
463
464        if fitness_host.is_empty() {
465            // A generation with no offspring (only reachable when lambda == 0, which
466            // CgpConfig::validate rejects). Defensive: advance the counter and emit a
467            // worst-case metric without touching selection, so tell is total for
468            // direct callers that bypass the validating harness.
469            state.generation += 1;
470            let m = StrategyMetrics::from_host_fitness(
471                state.generation,
472                &[f32::NEG_INFINITY],
473                state.best_fitness,
474            );
475            state.best_fitness = m.best_fitness_ever();
476            return (state, m);
477        }
478
479        if state.parent_fitness.is_none() {
480            // First tell: initial parent fitness. Sanitize so a NaN seed cannot
481            // masquerade as a finite parent in the later `>=` comparison.
482            state.parent_fitness = Some(crate::fitness::sanitize_fitness(fitness_host[0]));
483            state.generation += 1;
484            update_best(&mut state, &offspring, &fitness_host);
485            let m = StrategyMetrics::from_host_fitness(
486                state.generation,
487                &fitness_host,
488                state.best_fitness,
489            );
490            state.best_fitness = m.best_fitness_ever();
491            return (state, m);
492        }
493
494        // (1+λ): parent survives only if NO offspring strictly beats it;
495        // canonical CGP uses `>=` (under the maximise convention) to break
496        // ties in favor of offspring (neutral mutations accumulate).
497        // Sanitize NaN → −inf (worst) so a NaN offspring can never be picked as
498        // best; the raw `best_off_fit >= parent` check below then rejects it.
499        let best_off_idx = fitness_host
500            .iter()
501            .map(|&f| crate::fitness::sanitize_fitness(f))
502            .enumerate()
503            .max_by(|(_, a), (_, b)| a.total_cmp(b))
504            .map_or(0, |(i, _)| i);
505        let best_off_fit = crate::fitness::sanitize_fitness(fitness_host[best_off_idx]);
506        let parent_fit = state
507            .parent_fitness
508            .expect("parent_fitness is Some after the bootstrap tell");
509        // `total_cmp` (not `>=`) so the comparison is well-defined even at the
510        // `−∞` worst-sentinel; ties still favour the offspring (neutral drift).
511        if best_off_fit.total_cmp(&parent_fit) != std::cmp::Ordering::Less {
512            let device = offspring.device();
513            #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
514            let idx = Tensor::<B, 1, Int>::from_data(
515                TensorData::new(vec![best_off_idx as i32], [1]),
516                &device,
517            );
518            state.parent = offspring.clone().select(0, idx);
519            state.parent_fitness = Some(best_off_fit);
520        }
521
522        state.generation += 1;
523        update_best(&mut state, &offspring, &fitness_host);
524        let m =
525            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
526        state.best_fitness = m.best_fitness_ever();
527        (state, m)
528    }
529
530    /// Returns the best-so-far genome and its fitness, or `None` before the
531    /// first `tell` call.
532    fn best(&self, state: &CgpState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
533        state
534            .best_genome
535            .as_ref()
536            .map(|g| (g.clone(), state.best_fitness))
537    }
538}
539
540fn update_best<B: Backend>(state: &mut CgpState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
541    if fitness.is_empty() {
542        return;
543    }
544    // Sanitize (NaN → −∞) then order with `total_cmp`: the §3 correctness floor
545    // for a direct (non-harness) caller. `best_fitness` seeds at `−∞`, so a
546    // legitimately sanitized `−∞` fitness is treated as the worst, not skipped.
547    let sane: Vec<f32> = fitness
548        .iter()
549        .map(|&f| crate::fitness::sanitize_fitness(f))
550        .collect();
551    let mut best_idx = 0usize;
552    let mut best_f = sane[0];
553    for (i, &f) in sane.iter().enumerate().skip(1) {
554        if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
555            best_f = f;
556            best_idx = i;
557        }
558    }
559    if best_f.total_cmp(&state.best_fitness) == std::cmp::Ordering::Greater {
560        let device = pop.device();
561        #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
562        let idx =
563            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i32], [1]), &device);
564        state.best_genome = Some(pop.clone().select(0, idx));
565        state.best_fitness = best_f;
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::fitness::BatchFitnessFn;
573    use crate::strategy::EvolutionaryHarness;
574    use burn::backend::Flex;
575    type TestBackend = Flex;
576
577    #[test]
578    fn default_config_validates() {
579        assert!(CgpConfig::default_for(1).validate().is_ok());
580    }
581
582    #[test]
583    fn rejects_zero_rows() {
584        let mut cfg = CgpConfig::default_for(1);
585        cfg.rows = 0;
586        assert_eq!(cfg.validate().unwrap_err().field, "rows");
587    }
588
589    /// Defensive guard for the empty input pool (issue #154, `gp_cgp` §5.2).
590    /// With `n_inputs == 0` and `col == 0` both the built pool and the fallback
591    /// are empty, so the raw `random_range(0..0)` would panic. A direct caller
592    /// bypassing the validating harness must get a benign `(0, 0)` instead.
593    #[test]
594    fn sample_input_pair_empty_pool_returns_benign_zero() {
595        use rand::SeedableRng;
596        use rand::rngs::StdRng;
597
598        // Construct an invalid config (n_inputs == 0) by mutating a default,
599        // bypassing `validate` which would reject it.
600        let mut cfg = CgpConfig::default_for(1);
601        cfg.n_inputs = 0;
602        let mut rng = StdRng::seed_from_u64(7);
603        assert_eq!(super::sample_input_pair(0, &cfg, &mut rng), (0, 0));
604    }
605
606    /// Defensive guard for the empty `fitness_host` (issue #154, `gp_cgp` §5.3).
607    /// A generation with no offspring (`lambda == 0`) makes both the bootstrap
608    /// and selection paths index `fitness_host[0]` and panic. A direct caller
609    /// bypassing the validating harness must instead advance the generation
610    /// counter and return without touching selection or the parent fitness.
611    #[test]
612    fn tell_empty_fitness_does_not_panic() {
613        use rand::SeedableRng;
614        use rand::rngs::StdRng;
615
616        let device = Default::default();
617        let strategy = CartesianGeneticProgramming::<TestBackend>::new();
618
619        // Invalid config (lambda == 0) via mutation, bypassing `validate`.
620        let mut params = CgpConfig::default_for(1);
621        params.lambda = 0;
622        let mut rng = StdRng::seed_from_u64(11);
623        // Build the bootstrap state directly: `init` debug-asserts the config,
624        // which lambda == 0 would trip, and `tell` ignores `params` anyway.
625        let parent = Tensor::<TestBackend, 2, Int>::zeros([1, params.genome_len()], &device);
626        let state: CgpState<TestBackend> = CgpState {
627            parent,
628            parent_fitness: None,
629            best_genome: None,
630            best_fitness: f32::NEG_INFINITY,
631            generation: 0,
632        };
633
634        let empty_fitness =
635            Tensor::<TestBackend, 1>::from_data(TensorData::new(Vec::<f32>::new(), [0]), &device);
636        let empty_offspring = Tensor::<TestBackend, 2, Int>::from_data(
637            TensorData::new(Vec::<i32>::new(), [0, params.genome_len()]),
638            &device,
639        );
640
641        let (state1, _) = strategy.tell(&params, empty_offspring, empty_fitness, state, &mut rng);
642        assert_eq!(
643            state1.generation, 1,
644            "empty generation must advance the counter"
645        );
646        assert_eq!(
647            state1.parent_fitness, None,
648            "empty generation must not bootstrap or mutate parent fitness"
649        );
650    }
651
652    /// Regression for the `is_finite()` bootstrap sentinel vs the sanitize-to-`−∞`
653    /// convention (issue #132, `gp_cgp` §1.1 / ADR 0034). A canonical `−∞` parent
654    /// fitness (a `Minimize` `+∞` cost canonicalizes to `−∞`) must not re-trigger
655    /// the bootstrap branch: the `(1+λ)` loop has to keep emitting λ offspring.
656    #[test]
657    fn neg_inf_parent_fitness_does_not_collapse_lambda_loop() {
658        use rand::SeedableRng;
659        use rand::rngs::StdRng;
660
661        let device = Default::default();
662        let strategy = CartesianGeneticProgramming::<TestBackend>::new();
663        let params = CgpConfig::default_for(1);
664        let mut rng = StdRng::seed_from_u64(3);
665        let state = strategy.init(&params, &mut rng, &device);
666
667        // Bootstrap ask returns the single parent for initial evaluation.
668        let (boot, next) = strategy.ask(&params, &state, &mut rng, &device);
669        assert_eq!(boot.dims()[0], 1, "bootstrap ask returns the single parent");
670
671        // Bootstrap tell with a canonical −∞ fitness. Under the old
672        // `is_finite()` sentinel this left `parent_fitness` non-finite and the
673        // next `ask` collapsed back to a single genome.
674        let neg_inf = Tensor::<TestBackend, 1>::from_data(
675            TensorData::new(vec![f32::NEG_INFINITY], [1]),
676            &device,
677        );
678        let (state1, _) = strategy.tell(&params, boot, neg_inf, next, &mut rng);
679        assert_eq!(
680            state1.parent_fitness,
681            Some(f32::NEG_INFINITY),
682            "bootstrap must store the sanitized −∞ parent fitness, not re-arm the sentinel"
683        );
684
685        // Next ask must produce a full λ offspring population.
686        let (offspring, _) = strategy.ask(&params, &state1, &mut rng, &device);
687        assert_eq!(
688            offspring.dims()[0],
689            params.lambda,
690            "post-bootstrap ask must emit λ offspring even with a −∞ parent fitness"
691        );
692    }
693
694    /// `update_best` treats a sanitized `−∞` fitness as the worst value (never a
695    /// champion) yet still promotes a finite winner in the same generation
696    /// (issue #132, `gp_cgp` §1.1).
697    #[test]
698    fn update_best_treats_neg_inf_as_worst() {
699        let device = Default::default();
700        let parent = Tensor::<TestBackend, 2, Int>::zeros([3, 4], &device);
701        let mut state: CgpState<TestBackend> = CgpState {
702            parent: parent.clone(),
703            parent_fitness: None,
704            best_genome: None,
705            best_fitness: f32::NEG_INFINITY,
706            generation: 0,
707        };
708
709        // An all-`−∞` generation must not promote any champion.
710        update_best(&mut state, &parent, &[f32::NEG_INFINITY; 3]);
711        assert!(
712            state.best_genome.is_none(),
713            "an all −∞ generation must not promote a champion"
714        );
715
716        // A finite winner (index 1) is recorded despite the `−∞` neighbours.
717        update_best(
718            &mut state,
719            &parent,
720            &[f32::NEG_INFINITY, 2.5, f32::NEG_INFINITY],
721        );
722        approx::assert_relative_eq!(state.best_fitness, 2.5, epsilon = 1e-6);
723        assert!(
724            state.best_genome.is_some(),
725            "a finite winner must be recorded even beside −∞ members"
726        );
727    }
728
729    /// `mutate_genome` preserves genome length and, at `mutation_rate == 0.0`,
730    /// is a no-op (`rng.random() >= 0.0` always holds, so every gene is skipped).
731    #[test]
732    fn mutate_genome_zero_rate_is_length_preserving_noop() {
733        use rand::SeedableRng;
734        use rand::rngs::StdRng;
735
736        let mut params = CgpConfig::default_for(2);
737        let mut rng = StdRng::seed_from_u64(5);
738        let mut genome =
739            CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(&params, &mut rng);
740        let before = genome.clone();
741
742        params.mutation_rate = Probability::new(0.0);
743        mutate_genome(&mut genome, &params, &mut rng);
744        assert_eq!(genome.len(), before.len(), "length must be preserved");
745        assert_eq!(genome, before, "rate 0.0 must leave the genome untouched");
746    }
747
748    /// Feed-forward / `levels_back` invariant: for a fresh genome and for a
749    /// fully mutated one (`rate == 1.0`), every node input gene references only
750    /// an earlier node or a graph input, and node references stay within the
751    /// `levels_back` column window. Verified on a single-row grid so the node
752    /// global index is `n_inputs + col`.
753    #[test]
754    fn genome_respects_feedforward_and_levels_back() {
755        use rand::SeedableRng;
756        use rand::rngs::StdRng;
757
758        let n_inputs = 2usize;
759        let cols = 8usize;
760        let levels_back = 3usize;
761        let mut params = CgpConfig::default_for(n_inputs);
762        params.rows = 1;
763        params.cols = cols;
764        params.levels_back = levels_back;
765
766        // Assert the invariant for an arbitrary host genome layout.
767        let check = |genome: &[i64]| {
768            for col in 0..cols {
769                let base = col * CgpConfig::GENES_PER_NODE;
770                let func = genome[base];
771                #[allow(clippy::cast_possible_wrap)]
772                let num_funcs = NUM_FUNCTIONS as i64;
773                assert!(
774                    (0..num_funcs).contains(&func),
775                    "function id {func} out of range at col {col}"
776                );
777                #[allow(clippy::cast_possible_wrap)]
778                let node_global = (n_inputs + col) as i64;
779                #[allow(clippy::cast_possible_wrap)]
780                let n_in = n_inputs as i64;
781                #[allow(clippy::cast_possible_wrap)]
782                let min_node_ref = (n_inputs + col.saturating_sub(levels_back)) as i64;
783                for &inp in &[genome[base + 1], genome[base + 2]] {
784                    assert!(
785                        inp < node_global,
786                        "col {col}: input {inp} must be strictly earlier than node {node_global}"
787                    );
788                    assert!(
789                        inp < n_in || inp >= min_node_ref,
790                        "col {col}: node input {inp} outside the levels_back window \
791                         [{min_node_ref}, {node_global})"
792                    );
793                }
794            }
795        };
796
797        let mut rng = StdRng::seed_from_u64(19);
798        let genome =
799            CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(&params, &mut rng);
800        check(&genome);
801
802        // Fully mutate, then re-check: mutation must also honor the invariant.
803        let mut mutated = genome.clone();
804        params.mutation_rate = Probability::new(1.0);
805        mutate_genome(&mut mutated, &params, &mut rng);
806        assert_eq!(mutated.len(), genome.len(), "mutation preserves length");
807        check(&mutated);
808    }
809
810    /// Out-of-range input and output gene indices are clamped to the last
811    /// buffer slot rather than panicking, keeping evaluation robust to
812    /// mutated-but-unrepaired genotypes.
813    #[test]
814    fn evaluate_cgp_clamps_out_of_range_indices() {
815        let params = CgpConfig {
816            lambda: 1,
817            n_inputs: 1,
818            rows: 1,
819            cols: 1,
820            mutation_rate: Probability::new(0.1),
821            levels_back: usize::MAX,
822        };
823        // [func=add, in0, in1, output] with every index far out of range.
824        let genome: Vec<i64> = vec![0, 999, 999, 999];
825        let inputs: Vec<Vec<f32>> = vec![vec![2.0], vec![3.0]];
826        let out = evaluate_cgp(&genome, &params, &inputs);
827        assert_eq!(out.len(), inputs.len(), "one output per input row");
828        assert!(
829            out.iter().all(|v| v.is_finite()),
830            "clamped evaluation must stay finite, got {out:?}"
831        );
832    }
833
834    /// `best()` returns `None` before the first `tell` (no champion recorded
835    /// yet), matching the documented contract.
836    #[test]
837    fn best_is_none_before_first_tell() {
838        use rand::SeedableRng;
839        use rand::rngs::StdRng;
840
841        let device = Default::default();
842        let strategy = CartesianGeneticProgramming::<TestBackend>::new();
843        let params = CgpConfig::default_for(1);
844        let mut rng = StdRng::seed_from_u64(0);
845        let state = strategy.init(&params, &mut rng, &device);
846        assert!(
847            strategy.best(&state).is_none(),
848            "best must be None before the first tell"
849        );
850    }
851
852    /// Symbolic regression on `x² + 1` over 20 evenly spaced x ∈ [−1, 1].
853    struct SymRegression {
854        params: CgpConfig,
855        xs: Vec<f32>,
856        ys: Vec<f32>,
857    }
858
859    impl SymRegression {
860        #[allow(clippy::cast_precision_loss)]
861        fn new(params: CgpConfig) -> Self {
862            let xs: Vec<f32> = (0..20).map(|i| -1.0 + 2.0 * (i as f32) / 19.0).collect();
863            let ys: Vec<f32> = xs.iter().map(|x| x * x + 1.0).collect();
864            Self { params, xs, ys }
865        }
866    }
867
868    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for SymRegression {
869        #[allow(clippy::cast_precision_loss)]
870        fn evaluate_batch(
871            &mut self,
872            population: &Tensor<B, 2, Int>,
873            device: &<B as burn::tensor::backend::BackendTypes>::Device,
874        ) -> Tensor<B, 1> {
875            let pop_size = population.dims()[0];
876            let data: Vec<i64> = population
877                .clone()
878                .into_data()
879                .into_vec::<i32>()
880                .expect("genome host-read of a tensor this test just built")
881                .into_iter()
882                .map(i64::from)
883                .collect();
884            let gl = self.params.genome_len();
885            let inputs: Vec<Vec<f32>> = self.xs.iter().map(|&x| vec![x]).collect();
886            let mut fitness = Vec::with_capacity(pop_size);
887            for row in 0..pop_size {
888                let genome = &data[row * gl..(row + 1) * gl];
889                let preds = evaluate_cgp(genome, &self.params, &inputs);
890                let mse: f32 = preds
891                    .iter()
892                    .zip(self.ys.iter())
893                    .map(|(p, y)| (p - y).powi(2))
894                    .sum::<f32>()
895                    / (self.ys.len() as f32);
896                fitness.push(mse);
897            }
898            Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
899        }
900
901        fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
902            rlevo_core::objective::ObjectiveSense::Minimize
903        }
904    }
905
906    #[test]
907    #[allow(clippy::cast_precision_loss)]
908    fn cgp_reduces_error_on_square_plus_one() {
909        let device = Default::default();
910        let params = CgpConfig::default_for(1);
911        let landscape = SymRegression::new(params.clone());
912        let initial_error = {
913            // Baseline: random genome MSE on a single seed.
914            use rand::SeedableRng;
915            let mut rng = rand::rngs::StdRng::seed_from_u64(123);
916            let genome = CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(
917                &params, &mut rng,
918            );
919            let inputs: Vec<Vec<f32>> = landscape.xs.iter().map(|&x| vec![x]).collect();
920            let preds = evaluate_cgp(&genome, &params, &inputs);
921            preds
922                .iter()
923                .zip(landscape.ys.iter())
924                .map(|(p, y)| (p - y).powi(2))
925                .sum::<f32>()
926                / (landscape.ys.len() as f32)
927        };
928
929        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
930            CartesianGeneticProgramming::<TestBackend>::new(),
931            params,
932            landscape,
933            21,
934            device,
935            2000,
936        )
937        .expect("valid params");
938        harness.reset();
939        loop {
940            if harness.step(()).done {
941                break;
942            }
943        }
944        let best = harness.latest_metrics().unwrap().best_fitness_ever();
945        // CGP should substantially beat the random-genome baseline.
946        assert!(
947            best < initial_error,
948            "CGP did not improve: best={best} initial={initial_error}"
949        );
950        // Bias check: ought to beat predicting a constant y=1 (mean ~= 1.33).
951        assert!(best < 0.2, "expected MSE < 0.2 but got {best}");
952    }
953}