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 fixed at construction time:
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//! # Phenotype evaluation
33//!
34//! Evaluation runs on the host because the per-node dispatch is not a
35//! good fit for dense tensor ops; node values are computed in
36//! topological order (left-to-right across the grid columns).
37//! Genotype storage stays on-device to match the other strategies.
38//!
39//! # Reference
40//!
41//! - Miller (2011), *Cartesian Genetic Programming* (Natural Computing
42//!   Series).
43
44use std::marker::PhantomData;
45
46use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
47use rand::{Rng, RngExt};
48
49use crate::rng::{SeedPurpose, seed_stream};
50use crate::strategy::{Strategy, StrategyMetrics};
51
52/// Fixed v1 function set: arity of each opcode.
53pub const FUNCTION_ARITIES: [usize; 8] = [2, 2, 2, 2, 1, 1, 1, 0];
54/// Number of opcodes in the v1 function set.
55pub const NUM_FUNCTIONS: usize = FUNCTION_ARITIES.len();
56
57/// Static configuration for a [`CartesianGeneticProgramming`] run.
58#[derive(Debug, Clone)]
59pub struct CgpConfig {
60    /// Number of offspring per generation (λ in `(1 + λ)`).
61    pub lambda: usize,
62    /// Number of inputs (independent variables) the program sees.
63    pub n_inputs: usize,
64    /// Number of grid rows.
65    pub rows: usize,
66    /// Number of grid columns.
67    pub cols: usize,
68    /// Mutation rate applied to each gene of the integer genome.
69    pub mutation_rate: f32,
70    /// Levels-back parameter: how many previous columns a node can
71    /// connect to. `usize::MAX` means "any previous column".
72    pub levels_back: usize,
73}
74
75impl CgpConfig {
76    /// Sensible defaults: 1-output, 1-row, 30-column grid, mutation
77    /// rate tuned to flip ~3 genes per genome.
78    #[must_use]
79    pub fn default_for(n_inputs: usize) -> Self {
80        let rows = 1;
81        let cols = 30;
82        let genes_per_node = 3; // (function, input_0, input_1)
83        let output_genes = 1;
84        let total_genes = rows * cols * genes_per_node + output_genes;
85        #[allow(clippy::cast_precision_loss)]
86        let mutation_rate = 3.0 / total_genes as f32;
87        Self {
88            lambda: 4,
89            n_inputs,
90            rows,
91            cols,
92            mutation_rate,
93            levels_back: usize::MAX,
94        }
95    }
96
97    /// Genes per node in the genotype layout.
98    pub const GENES_PER_NODE: usize = 3;
99    /// Number of output genes (one per program output).
100    pub const OUTPUT_GENES: usize = 1;
101
102    /// Total genome length (nodes × 3 + outputs).
103    #[must_use]
104    pub fn genome_len(&self) -> usize {
105        self.rows * self.cols * Self::GENES_PER_NODE + Self::OUTPUT_GENES
106    }
107}
108
109/// Generation state for [`CartesianGeneticProgramming`].
110#[derive(Debug, Clone)]
111pub struct CgpState<B: Backend> {
112    /// Parent genotype, shape `(1, genome_len)`.
113    pub parent: Tensor<B, 2, Int>,
114    /// Parent fitness (host-side scalar cache).
115    pub parent_fitness: f32,
116    /// Best-so-far genotype.
117    pub best_genome: Option<Tensor<B, 2, Int>>,
118    /// Best-so-far fitness.
119    pub best_fitness: f32,
120    /// Generation counter.
121    pub generation: usize,
122}
123
124/// Classical Cartesian GP with `(1 + λ)` ES.
125///
126/// # Example
127///
128/// ```no_run
129/// use burn::backend::NdArray;
130/// use rlevo_evolution::algorithms::gp_cgp::{CartesianGeneticProgramming, CgpConfig};
131///
132/// let strategy = CartesianGeneticProgramming::<NdArray>::new();
133/// let params = CgpConfig::default_for(1);
134/// assert!(params.genome_len() > 0);
135/// let _ = strategy;
136/// ```
137#[derive(Debug, Clone, Copy, Default)]
138pub struct CartesianGeneticProgramming<B: Backend> {
139    _backend: PhantomData<fn() -> B>,
140}
141
142impl<B: Backend> CartesianGeneticProgramming<B> {
143    /// Builds a new (stateless) strategy object.
144    #[must_use]
145    pub fn new() -> Self {
146        Self {
147            _backend: PhantomData,
148        }
149    }
150
151    fn sample_initial_genome(params: &CgpConfig, rng: &mut dyn Rng) -> Vec<i64> {
152        let mut genome = Vec::with_capacity(params.genome_len());
153        for col in 0..params.cols {
154            for _row in 0..params.rows {
155                #[allow(clippy::cast_possible_wrap)]
156                let func = rng.random_range(0..NUM_FUNCTIONS as i64);
157                let (inp0, inp1) = sample_input_pair(col, params, rng);
158                genome.push(func);
159                genome.push(inp0);
160                genome.push(inp1);
161            }
162        }
163        // Output gene: any node index or input index.
164        let max_node_idx = params.n_inputs + params.rows * params.cols;
165        #[allow(clippy::cast_possible_wrap)]
166        genome.push(rng.random_range(0..max_node_idx as i64));
167        genome
168    }
169
170    fn genome_to_host(genome: &Tensor<B, 2, Int>) -> Vec<i64> {
171        genome
172            .clone()
173            .into_data()
174            .into_vec::<i64>()
175            .unwrap_or_default()
176    }
177}
178
179fn sample_input_pair(col: usize, params: &CgpConfig, rng: &mut dyn Rng) -> (i64, i64) {
180    let min_col = col.saturating_sub(params.levels_back);
181    let node_indices_start = params.n_inputs + min_col * params.rows;
182    let node_indices_end = params.n_inputs + col * params.rows;
183    let max = node_indices_end.max(params.n_inputs);
184    // Allowed inputs: 0..n_inputs (graph inputs) ∪ previous nodes.
185    let input_count = params.n_inputs
186        + (max - params.n_inputs)
187            .saturating_sub(node_indices_start.saturating_sub(params.n_inputs));
188    let pool: Vec<i64> = (0..params.n_inputs)
189        .chain(node_indices_start..node_indices_end)
190        .map(|i| {
191            #[allow(clippy::cast_possible_wrap)]
192            let v = i as i64;
193            v
194        })
195        .collect();
196    let pool = if pool.is_empty() {
197        #[allow(clippy::cast_possible_wrap)]
198        (0..params.n_inputs as i64).collect()
199    } else {
200        pool
201    };
202    let _ = input_count;
203    let pick = |rng: &mut dyn Rng| -> i64 {
204        let idx = rng.random_range(0..pool.len());
205        pool[idx]
206    };
207    (pick(rng), pick(rng))
208}
209
210fn mutate_genome(genome: &mut [i64], params: &CgpConfig, rng: &mut dyn Rng) {
211    let genes_per_node = CgpConfig::GENES_PER_NODE;
212    let node_genes = params.rows * params.cols * genes_per_node;
213    for (gene_idx, gene) in genome.iter_mut().enumerate() {
214        if rng.random::<f32>() >= params.mutation_rate {
215            continue;
216        }
217        if gene_idx < node_genes {
218            let within = gene_idx % genes_per_node;
219            let node_idx = gene_idx / genes_per_node;
220            let col = node_idx / params.rows;
221            if within == 0 {
222                // function
223                #[allow(clippy::cast_possible_wrap)]
224                {
225                    *gene = rng.random_range(0..NUM_FUNCTIONS as i64);
226                }
227            } else {
228                let (new0, new1) = sample_input_pair(col, params, rng);
229                *gene = if within == 1 { new0 } else { new1 };
230            }
231        } else {
232            // output gene
233            let max_node_idx = params.n_inputs + params.rows * params.cols;
234            #[allow(clippy::cast_possible_wrap)]
235            {
236                *gene = rng.random_range(0..max_node_idx as i64);
237            }
238        }
239    }
240}
241
242/// Evaluates a CGP genotype at a set of input rows.
243///
244/// `genome` is the host-side integer genotype (length `params.genome_len()`).
245/// `inputs` is a slice of `n_samples` rows, each of length `params.n_inputs`.
246/// Returns one `f32` output per input row.
247///
248/// Out-of-range input/node indices in the genome are clamped to the
249/// last buffer slot rather than panicking — this keeps fitness
250/// evaluation robust to mutated-but-unrepaired genotypes. Non-finite
251/// node values (e.g., `inf` from divisions or `tan`) collapse to `0.0`.
252///
253/// # Panics
254///
255/// Panics if `genome` is empty (the last gene is the output index).
256#[must_use]
257pub fn evaluate_cgp(genome: &[i64], params: &CgpConfig, inputs: &[Vec<f32>]) -> Vec<f32> {
258    let node_count = params.rows * params.cols;
259    let n_inputs = params.n_inputs;
260    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
261    let output_idx = genome[genome.len() - 1] as usize;
262
263    let mut outputs = Vec::with_capacity(inputs.len());
264    let mut buf = vec![0.0_f32; n_inputs + node_count];
265
266    for sample in inputs {
267        for (i, v) in sample.iter().enumerate() {
268            buf[i] = *v;
269        }
270        for node in 0..node_count {
271            let base = node * 3;
272            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
273            let func = genome[base] as usize;
274            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
275            let a_idx = genome[base + 1] as usize;
276            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
277            let b_idx = genome[base + 2] as usize;
278            let a = buf[a_idx.min(buf.len() - 1)];
279            let b = buf[b_idx.min(buf.len() - 1)];
280            let v = match func {
281                0 => a + b,
282                1 => a - b,
283                2 => a * b,
284                3 => {
285                    if b.abs() < 1e-6 {
286                        a
287                    } else {
288                        a / b
289                    }
290                }
291                4 => a.sin(),
292                5 => a.cos(),
293                6 => a.tanh(),
294                7 => 1.0,
295                _ => 0.0,
296            };
297            buf[n_inputs + node] = if v.is_finite() { v } else { 0.0 };
298        }
299        outputs.push(buf[output_idx.min(buf.len() - 1)]);
300    }
301
302    outputs
303}
304
305impl<B: Backend> Strategy<B> for CartesianGeneticProgramming<B>
306where
307    B::Device: Clone,
308{
309    type Params = CgpConfig;
310    type State = CgpState<B>;
311    type Genome = Tensor<B, 2, Int>;
312
313    fn init(&self, params: &CgpConfig, rng: &mut dyn Rng, device: &B::Device) -> CgpState<B> {
314        let genome_vec = Self::sample_initial_genome(params, rng);
315        let parent = Tensor::<B, 2, Int>::from_data(
316            TensorData::new(genome_vec, [1, params.genome_len()]),
317            device,
318        );
319        CgpState {
320            parent,
321            parent_fitness: f32::INFINITY,
322            best_genome: None,
323            best_fitness: f32::INFINITY,
324            generation: 0,
325        }
326    }
327
328    fn ask(
329        &self,
330        params: &CgpConfig,
331        state: &CgpState<B>,
332        rng: &mut dyn Rng,
333        device: &B::Device,
334    ) -> (Tensor<B, 2, Int>, CgpState<B>) {
335        // First call: evaluate the parent as "offspring" of size 1.
336        if !state.parent_fitness.is_finite() {
337            return (state.parent.clone(), state.clone());
338        }
339
340        let mut mut_rng = seed_stream(
341            rng.next_u64(),
342            state.generation as u64,
343            SeedPurpose::Mutation,
344        );
345        let parent_vec = Self::genome_to_host(&state.parent);
346        let mut offspring_genomes: Vec<i64> =
347            Vec::with_capacity(params.lambda * params.genome_len());
348        for _ in 0..params.lambda {
349            let mut child = parent_vec.clone();
350            mutate_genome(&mut child, params, &mut mut_rng);
351            offspring_genomes.extend(child);
352        }
353        let offspring = Tensor::<B, 2, Int>::from_data(
354            TensorData::new(offspring_genomes, [params.lambda, params.genome_len()]),
355            device,
356        );
357        (offspring, state.clone())
358    }
359
360    fn tell(
361        &self,
362        _params: &CgpConfig,
363        offspring: Tensor<B, 2, Int>,
364        fitness: Tensor<B, 1>,
365        mut state: CgpState<B>,
366        _rng: &mut dyn Rng,
367    ) -> (CgpState<B>, StrategyMetrics) {
368        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
369
370        if !state.parent_fitness.is_finite() {
371            // First tell: initial parent fitness.
372            state.parent_fitness = fitness_host[0];
373            state.generation += 1;
374            update_best(&mut state, &offspring, &fitness_host);
375            let m = StrategyMetrics::from_host_fitness(
376                state.generation,
377                &fitness_host,
378                state.best_fitness,
379            );
380            state.best_fitness = m.best_fitness_ever;
381            return (state, m);
382        }
383
384        // (1+λ): parent survives only if NO offspring strictly beats it;
385        // canonical CGP uses `<=` to break ties in favor of offspring
386        // (neutral mutations accumulate).
387        let best_off_idx = fitness_host
388            .iter()
389            .enumerate()
390            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
391            .map_or(0, |(i, _)| i);
392        let best_off_fit = fitness_host[best_off_idx];
393        if best_off_fit <= state.parent_fitness {
394            let device = offspring.device();
395            #[allow(clippy::cast_possible_wrap)]
396            let idx = Tensor::<B, 1, Int>::from_data(
397                TensorData::new(vec![best_off_idx as i64], [1]),
398                &device,
399            );
400            state.parent = offspring.clone().select(0, idx);
401            state.parent_fitness = best_off_fit;
402        }
403
404        state.generation += 1;
405        update_best(&mut state, &offspring, &fitness_host);
406        let m =
407            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
408        state.best_fitness = m.best_fitness_ever;
409        (state, m)
410    }
411
412    fn best(&self, state: &CgpState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
413        state
414            .best_genome
415            .as_ref()
416            .map(|g| (g.clone(), state.best_fitness))
417    }
418}
419
420fn update_best<B: Backend>(state: &mut CgpState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
421    if fitness.is_empty() {
422        return;
423    }
424    let mut best_idx = 0usize;
425    let mut best_f = fitness[0];
426    for (i, &f) in fitness.iter().enumerate().skip(1) {
427        if f < best_f {
428            best_f = f;
429            best_idx = i;
430        }
431    }
432    if best_f < state.best_fitness {
433        let device = pop.device();
434        #[allow(clippy::cast_possible_wrap)]
435        let idx =
436            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
437        state.best_genome = Some(pop.clone().select(0, idx));
438        state.best_fitness = best_f;
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::fitness::BatchFitnessFn;
446    use crate::strategy::EvolutionaryHarness;
447    use burn::backend::NdArray;
448    type TestBackend = NdArray;
449
450    /// Symbolic regression on `x² + 1` over 20 evenly spaced x ∈ [−1, 1].
451    struct SymRegression {
452        params: CgpConfig,
453        xs: Vec<f32>,
454        ys: Vec<f32>,
455    }
456
457    impl SymRegression {
458        #[allow(clippy::cast_precision_loss)]
459        fn new(params: CgpConfig) -> Self {
460            let xs: Vec<f32> = (0..20).map(|i| -1.0 + 2.0 * (i as f32) / 19.0).collect();
461            let ys: Vec<f32> = xs.iter().map(|x| x * x + 1.0).collect();
462            Self { params, xs, ys }
463        }
464    }
465
466    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for SymRegression {
467        #[allow(clippy::cast_precision_loss)]
468        fn evaluate_batch(
469            &mut self,
470            population: &Tensor<B, 2, Int>,
471            device: &B::Device,
472        ) -> Tensor<B, 1> {
473            let pop_size = population.shape().dims[0];
474            let data = population.clone().into_data().into_vec::<i64>().unwrap();
475            let gl = self.params.genome_len();
476            let inputs: Vec<Vec<f32>> = self.xs.iter().map(|&x| vec![x]).collect();
477            let mut fitness = Vec::with_capacity(pop_size);
478            for row in 0..pop_size {
479                let genome = &data[row * gl..(row + 1) * gl];
480                let preds = evaluate_cgp(genome, &self.params, &inputs);
481                let mse: f32 = preds
482                    .iter()
483                    .zip(self.ys.iter())
484                    .map(|(p, y)| (p - y).powi(2))
485                    .sum::<f32>()
486                    / (self.ys.len() as f32);
487                fitness.push(mse);
488            }
489            Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
490        }
491    }
492
493    #[test]
494    #[allow(clippy::cast_precision_loss)]
495    fn cgp_reduces_error_on_square_plus_one() {
496        let device = Default::default();
497        let params = CgpConfig::default_for(1);
498        let landscape = SymRegression::new(params.clone());
499        let initial_error = {
500            // Baseline: random genome MSE on a single seed.
501            use rand::SeedableRng;
502            let mut rng = rand::rngs::StdRng::seed_from_u64(123);
503            let genome = CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(
504                &params, &mut rng,
505            );
506            let inputs: Vec<Vec<f32>> = landscape.xs.iter().map(|&x| vec![x]).collect();
507            let preds = evaluate_cgp(&genome, &params, &inputs);
508            preds
509                .iter()
510                .zip(landscape.ys.iter())
511                .map(|(p, y)| (p - y).powi(2))
512                .sum::<f32>()
513                / (landscape.ys.len() as f32)
514        };
515
516        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
517            CartesianGeneticProgramming::<TestBackend>::new(),
518            params,
519            landscape,
520            21,
521            device,
522            2000,
523        );
524        harness.reset();
525        loop {
526            if harness.step(()).done {
527                break;
528            }
529        }
530        let best = harness.latest_metrics().unwrap().best_fitness_ever;
531        // CGP should substantially beat the random-genome baseline.
532        assert!(
533            best < initial_error,
534            "CGP did not improve: best={best} initial={initial_error}"
535        );
536        // Bias check: ought to beat predicting a constant y=1 (mean ~= 1.33).
537        assert!(best < 0.2, "expected MSE < 0.2 but got {best}");
538    }
539}