use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::{Rng, RngExt};
use rlevo_core::config::{self, ConfigError, Validate};
use rlevo_core::probability::Probability;
use crate::function_set::{ArithmeticFunctionSet, FunctionSet, Symbol};
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
pub const FUNCTION_ARITIES: [usize; 8] = [2, 2, 2, 2, 1, 1, 1, 0];
pub const NUM_FUNCTIONS: usize = FUNCTION_ARITIES.len();
#[derive(Debug, Clone)]
pub struct CgpConfig {
pub lambda: usize,
pub n_inputs: usize,
pub rows: usize,
pub cols: usize,
pub mutation_rate: Probability,
pub levels_back: usize,
}
impl CgpConfig {
#[must_use]
pub fn default_for(n_inputs: usize) -> Self {
let rows = 1;
let cols = 30;
let genes_per_node = 3; let output_genes = 1;
let total_genes = rows * cols * genes_per_node + output_genes;
#[allow(clippy::cast_precision_loss)]
let mutation_rate = Probability::new(3.0 / total_genes as f32);
Self {
lambda: 4,
n_inputs,
rows,
cols,
mutation_rate,
levels_back: usize::MAX,
}
}
pub const GENES_PER_NODE: usize = 3;
pub const OUTPUT_GENES: usize = 1;
#[must_use]
pub fn genome_len(&self) -> usize {
self.rows * self.cols * Self::GENES_PER_NODE + Self::OUTPUT_GENES
}
}
impl Validate for CgpConfig {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "CgpConfig";
config::at_least(C, "lambda", self.lambda, 1)?;
config::at_least(C, "n_inputs", self.n_inputs, 1)?;
config::at_least(C, "rows", self.rows, 1)?;
config::at_least(C, "cols", self.cols, 1)?;
config::at_least(C, "levels_back", self.levels_back, 1)?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CgpState<B: Backend> {
pub parent: Tensor<B, 2, Int>,
pub parent_fitness: Option<f32>,
pub best_genome: Option<Tensor<B, 2, Int>>,
pub best_fitness: f32,
pub generation: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CartesianGeneticProgramming<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> CartesianGeneticProgramming<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
fn sample_initial_genome(params: &CgpConfig, rng: &mut dyn Rng) -> Vec<i64> {
let mut genome = Vec::with_capacity(params.genome_len());
for col in 0..params.cols {
for _row in 0..params.rows {
#[allow(clippy::cast_possible_wrap)]
let func = rng.random_range(0..NUM_FUNCTIONS as i64);
let (inp0, inp1) = sample_input_pair(col, params, rng);
genome.push(func);
genome.push(inp0);
genome.push(inp1);
}
}
let max_node_idx = params.n_inputs + params.rows * params.cols;
#[allow(clippy::cast_possible_wrap)]
genome.push(rng.random_range(0..max_node_idx as i64));
genome
}
fn genome_to_host(genome: &Tensor<B, 2, Int>) -> Vec<i64> {
genome
.clone()
.into_data()
.into_vec::<i32>()
.expect("genome tensor must be readable as i32")
.into_iter()
.map(i64::from)
.collect()
}
}
fn sample_input_pair(col: usize, params: &CgpConfig, rng: &mut dyn Rng) -> (i64, i64) {
let min_col = col.saturating_sub(params.levels_back);
let node_indices_start = params.n_inputs + min_col * params.rows;
let node_indices_end = params.n_inputs + col * params.rows;
let max = node_indices_end.max(params.n_inputs);
let input_count = params.n_inputs
+ (max - params.n_inputs)
.saturating_sub(node_indices_start.saturating_sub(params.n_inputs));
let pool: Vec<i64> = (0..params.n_inputs)
.chain(node_indices_start..node_indices_end)
.map(|i| {
#[allow(clippy::cast_possible_wrap)]
let v = i as i64;
v
})
.collect();
let pool = if pool.is_empty() {
#[allow(clippy::cast_possible_wrap)]
(0..params.n_inputs as i64).collect()
} else {
pool
};
let _ = input_count;
if pool.is_empty() {
return (0, 0);
}
let pick = |rng: &mut dyn Rng| -> i64 {
let idx = rng.random_range(0..pool.len());
pool[idx]
};
(pick(rng), pick(rng))
}
fn mutate_genome(genome: &mut [i64], params: &CgpConfig, rng: &mut dyn Rng) {
let genes_per_node = CgpConfig::GENES_PER_NODE;
let node_genes = params.rows * params.cols * genes_per_node;
for (gene_idx, gene) in genome.iter_mut().enumerate() {
if rng.random::<f32>() >= params.mutation_rate.get() {
continue;
}
if gene_idx < node_genes {
let within = gene_idx % genes_per_node;
let node_idx = gene_idx / genes_per_node;
let col = node_idx / params.rows;
if within == 0 {
#[allow(clippy::cast_possible_wrap)]
{
*gene = rng.random_range(0..NUM_FUNCTIONS as i64);
}
} else {
let (new0, new1) = sample_input_pair(col, params, rng);
*gene = if within == 1 { new0 } else { new1 };
}
} else {
let max_node_idx = params.n_inputs + params.rows * params.cols;
#[allow(clippy::cast_possible_wrap)]
{
*gene = rng.random_range(0..max_node_idx as i64);
}
}
}
}
#[must_use]
pub fn evaluate_cgp(genome: &[i64], params: &CgpConfig, inputs: &[Vec<f32>]) -> Vec<f32> {
evaluate_cgp_with(genome, params, inputs, &ArithmeticFunctionSet)
}
#[must_use]
pub fn evaluate_cgp_with<F: FunctionSet>(
genome: &[i64],
params: &CgpConfig,
inputs: &[Vec<f32>],
fs: &F,
) -> Vec<f32> {
let node_count = params.rows * params.cols;
let n_inputs = params.n_inputs;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let output_idx = genome[genome.len() - 1] as usize;
let mut outputs = Vec::with_capacity(inputs.len());
let mut buf = vec![0.0_f32; n_inputs + node_count];
for sample in inputs {
for (i, v) in sample.iter().enumerate() {
buf[i] = *v;
}
for node in 0..node_count {
let base = node * 3;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let func = genome[base] as i32;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let a_idx = genome[base + 1] as usize;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let b_idx = genome[base + 2] as usize;
let a = buf[a_idx.min(buf.len() - 1)];
let b = buf[b_idx.min(buf.len() - 1)];
let sym = Symbol::from_raw(func);
let arity = fs.arity(sym);
let arg_buf = [a, b];
let v = fs.apply(sym, &arg_buf[..arity.min(arg_buf.len())]);
buf[n_inputs + node] = crate::function_set::finite_or_zero(v);
}
outputs.push(buf[output_idx.min(buf.len() - 1)]);
}
outputs
}
impl<B: Backend> Strategy<B> for CartesianGeneticProgramming<B>
where
B::Device: Clone,
{
type Params = CgpConfig;
type State = CgpState<B>;
type Genome = Tensor<B, 2, Int>;
fn init(
&self,
params: &CgpConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> CgpState<B> {
debug_assert!(
params.validate().is_ok(),
"invalid CgpConfig reached init: {params:?}"
);
let genome_vec = Self::sample_initial_genome(params, rng);
let parent = Tensor::<B, 2, Int>::from_data(
TensorData::new(genome_vec, [1, params.genome_len()]),
device,
);
CgpState {
parent,
parent_fitness: None,
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &CgpConfig,
state: &CgpState<B>,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<B, 2, Int>, CgpState<B>) {
if state.parent_fitness.is_none() {
return (state.parent.clone(), state.clone());
}
let mut mut_rng = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Mutation,
);
let parent_vec = Self::genome_to_host(&state.parent);
let mut offspring_genomes: Vec<i64> =
Vec::with_capacity(params.lambda * params.genome_len());
for _ in 0..params.lambda {
let mut child = parent_vec.clone();
mutate_genome(&mut child, params, &mut mut_rng);
offspring_genomes.extend(child);
}
#[allow(clippy::cast_possible_truncation)]
let offspring_genomes_i32: Vec<i32> =
offspring_genomes.into_iter().map(|v| v as i32).collect();
let offspring = Tensor::<B, 2, Int>::from_data(
TensorData::new(offspring_genomes_i32, [params.lambda, params.genome_len()]),
device,
);
(offspring, state.clone())
}
fn tell(
&self,
_params: &CgpConfig,
offspring: Tensor<B, 2, Int>,
fitness: Tensor<B, 1>,
mut state: CgpState<B>,
_rng: &mut dyn Rng,
) -> (CgpState<B>, StrategyMetrics) {
let fitness_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
if fitness_host.is_empty() {
state.generation += 1;
let m = StrategyMetrics::from_host_fitness(
state.generation,
&[f32::NEG_INFINITY],
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever();
return (state, m);
}
if state.parent_fitness.is_none() {
state.parent_fitness = Some(crate::fitness::sanitize_fitness(fitness_host[0]));
state.generation += 1;
update_best(&mut state, &offspring, &fitness_host);
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever();
return (state, m);
}
let best_off_idx = fitness_host
.iter()
.map(|&f| crate::fitness::sanitize_fitness(f))
.enumerate()
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map_or(0, |(i, _)| i);
let best_off_fit = crate::fitness::sanitize_fitness(fitness_host[best_off_idx]);
let parent_fit = state
.parent_fitness
.expect("parent_fitness is Some after the bootstrap tell");
if best_off_fit.total_cmp(&parent_fit) != std::cmp::Ordering::Less {
let device = offspring.device();
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_off_idx as i32], [1]),
&device,
);
state.parent = offspring.clone().select(0, idx);
state.parent_fitness = Some(best_off_fit);
}
state.generation += 1;
update_best(&mut state, &offspring, &fitness_host);
let m =
StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
state.best_fitness = m.best_fitness_ever();
(state, m)
}
fn best(&self, state: &CgpState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
state
.best_genome
.as_ref()
.map(|g| (g.clone(), state.best_fitness))
}
}
fn update_best<B: Backend>(state: &mut CgpState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
if fitness.is_empty() {
return;
}
let sane: Vec<f32> = fitness
.iter()
.map(|&f| crate::fitness::sanitize_fitness(f))
.collect();
let mut best_idx = 0usize;
let mut best_f = sane[0];
for (i, &f) in sane.iter().enumerate().skip(1) {
if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
best_f = f;
best_idx = i;
}
}
if best_f.total_cmp(&state.best_fitness) == std::cmp::Ordering::Greater {
let device = pop.device();
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let idx =
Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i32], [1]), &device);
state.best_genome = Some(pop.clone().select(0, idx));
state.best_fitness = best_f;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::BatchFitnessFn;
use crate::strategy::EvolutionaryHarness;
use burn::backend::Flex;
type TestBackend = Flex;
#[test]
fn default_config_validates() {
assert!(CgpConfig::default_for(1).validate().is_ok());
}
#[test]
fn rejects_zero_rows() {
let mut cfg = CgpConfig::default_for(1);
cfg.rows = 0;
assert_eq!(cfg.validate().unwrap_err().field, "rows");
}
#[test]
fn sample_input_pair_empty_pool_returns_benign_zero() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let mut cfg = CgpConfig::default_for(1);
cfg.n_inputs = 0;
let mut rng = StdRng::seed_from_u64(7);
assert_eq!(super::sample_input_pair(0, &cfg, &mut rng), (0, 0));
}
#[test]
fn tell_empty_fitness_does_not_panic() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = CartesianGeneticProgramming::<TestBackend>::new();
let mut params = CgpConfig::default_for(1);
params.lambda = 0;
let mut rng = StdRng::seed_from_u64(11);
let parent = Tensor::<TestBackend, 2, Int>::zeros([1, params.genome_len()], &device);
let state: CgpState<TestBackend> = CgpState {
parent,
parent_fitness: None,
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
};
let empty_fitness =
Tensor::<TestBackend, 1>::from_data(TensorData::new(Vec::<f32>::new(), [0]), &device);
let empty_offspring = Tensor::<TestBackend, 2, Int>::from_data(
TensorData::new(Vec::<i32>::new(), [0, params.genome_len()]),
&device,
);
let (state1, _) = strategy.tell(¶ms, empty_offspring, empty_fitness, state, &mut rng);
assert_eq!(
state1.generation, 1,
"empty generation must advance the counter"
);
assert_eq!(
state1.parent_fitness, None,
"empty generation must not bootstrap or mutate parent fitness"
);
}
#[test]
fn neg_inf_parent_fitness_does_not_collapse_lambda_loop() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = CartesianGeneticProgramming::<TestBackend>::new();
let params = CgpConfig::default_for(1);
let mut rng = StdRng::seed_from_u64(3);
let state = strategy.init(¶ms, &mut rng, &device);
let (boot, next) = strategy.ask(¶ms, &state, &mut rng, &device);
assert_eq!(boot.dims()[0], 1, "bootstrap ask returns the single parent");
let neg_inf = Tensor::<TestBackend, 1>::from_data(
TensorData::new(vec![f32::NEG_INFINITY], [1]),
&device,
);
let (state1, _) = strategy.tell(¶ms, boot, neg_inf, next, &mut rng);
assert_eq!(
state1.parent_fitness,
Some(f32::NEG_INFINITY),
"bootstrap must store the sanitized −∞ parent fitness, not re-arm the sentinel"
);
let (offspring, _) = strategy.ask(¶ms, &state1, &mut rng, &device);
assert_eq!(
offspring.dims()[0],
params.lambda,
"post-bootstrap ask must emit λ offspring even with a −∞ parent fitness"
);
}
#[test]
fn update_best_treats_neg_inf_as_worst() {
let device = Default::default();
let parent = Tensor::<TestBackend, 2, Int>::zeros([3, 4], &device);
let mut state: CgpState<TestBackend> = CgpState {
parent: parent.clone(),
parent_fitness: None,
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
};
update_best(&mut state, &parent, &[f32::NEG_INFINITY; 3]);
assert!(
state.best_genome.is_none(),
"an all −∞ generation must not promote a champion"
);
update_best(
&mut state,
&parent,
&[f32::NEG_INFINITY, 2.5, f32::NEG_INFINITY],
);
approx::assert_relative_eq!(state.best_fitness, 2.5, epsilon = 1e-6);
assert!(
state.best_genome.is_some(),
"a finite winner must be recorded even beside −∞ members"
);
}
#[test]
fn mutate_genome_zero_rate_is_length_preserving_noop() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let mut params = CgpConfig::default_for(2);
let mut rng = StdRng::seed_from_u64(5);
let mut genome =
CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(¶ms, &mut rng);
let before = genome.clone();
params.mutation_rate = Probability::new(0.0);
mutate_genome(&mut genome, ¶ms, &mut rng);
assert_eq!(genome.len(), before.len(), "length must be preserved");
assert_eq!(genome, before, "rate 0.0 must leave the genome untouched");
}
#[test]
fn genome_respects_feedforward_and_levels_back() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let n_inputs = 2usize;
let cols = 8usize;
let levels_back = 3usize;
let mut params = CgpConfig::default_for(n_inputs);
params.rows = 1;
params.cols = cols;
params.levels_back = levels_back;
let check = |genome: &[i64]| {
for col in 0..cols {
let base = col * CgpConfig::GENES_PER_NODE;
let func = genome[base];
#[allow(clippy::cast_possible_wrap)]
let num_funcs = NUM_FUNCTIONS as i64;
assert!(
(0..num_funcs).contains(&func),
"function id {func} out of range at col {col}"
);
#[allow(clippy::cast_possible_wrap)]
let node_global = (n_inputs + col) as i64;
#[allow(clippy::cast_possible_wrap)]
let n_in = n_inputs as i64;
#[allow(clippy::cast_possible_wrap)]
let min_node_ref = (n_inputs + col.saturating_sub(levels_back)) as i64;
for &inp in &[genome[base + 1], genome[base + 2]] {
assert!(
inp < node_global,
"col {col}: input {inp} must be strictly earlier than node {node_global}"
);
assert!(
inp < n_in || inp >= min_node_ref,
"col {col}: node input {inp} outside the levels_back window \
[{min_node_ref}, {node_global})"
);
}
}
};
let mut rng = StdRng::seed_from_u64(19);
let genome =
CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(¶ms, &mut rng);
check(&genome);
let mut mutated = genome.clone();
params.mutation_rate = Probability::new(1.0);
mutate_genome(&mut mutated, ¶ms, &mut rng);
assert_eq!(mutated.len(), genome.len(), "mutation preserves length");
check(&mutated);
}
#[test]
fn evaluate_cgp_clamps_out_of_range_indices() {
let params = CgpConfig {
lambda: 1,
n_inputs: 1,
rows: 1,
cols: 1,
mutation_rate: Probability::new(0.1),
levels_back: usize::MAX,
};
let genome: Vec<i64> = vec![0, 999, 999, 999];
let inputs: Vec<Vec<f32>> = vec![vec![2.0], vec![3.0]];
let out = evaluate_cgp(&genome, ¶ms, &inputs);
assert_eq!(out.len(), inputs.len(), "one output per input row");
assert!(
out.iter().all(|v| v.is_finite()),
"clamped evaluation must stay finite, got {out:?}"
);
}
#[test]
fn best_is_none_before_first_tell() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = CartesianGeneticProgramming::<TestBackend>::new();
let params = CgpConfig::default_for(1);
let mut rng = StdRng::seed_from_u64(0);
let state = strategy.init(¶ms, &mut rng, &device);
assert!(
strategy.best(&state).is_none(),
"best must be None before the first tell"
);
}
struct SymRegression {
params: CgpConfig,
xs: Vec<f32>,
ys: Vec<f32>,
}
impl SymRegression {
#[allow(clippy::cast_precision_loss)]
fn new(params: CgpConfig) -> Self {
let xs: Vec<f32> = (0..20).map(|i| -1.0 + 2.0 * (i as f32) / 19.0).collect();
let ys: Vec<f32> = xs.iter().map(|x| x * x + 1.0).collect();
Self { params, xs, ys }
}
}
impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for SymRegression {
#[allow(clippy::cast_precision_loss)]
fn evaluate_batch(
&mut self,
population: &Tensor<B, 2, Int>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 1> {
let pop_size = population.dims()[0];
let data: Vec<i64> = population
.clone()
.into_data()
.into_vec::<i32>()
.expect("genome host-read of a tensor this test just built")
.into_iter()
.map(i64::from)
.collect();
let gl = self.params.genome_len();
let inputs: Vec<Vec<f32>> = self.xs.iter().map(|&x| vec![x]).collect();
let mut fitness = Vec::with_capacity(pop_size);
for row in 0..pop_size {
let genome = &data[row * gl..(row + 1) * gl];
let preds = evaluate_cgp(genome, &self.params, &inputs);
let mse: f32 = preds
.iter()
.zip(self.ys.iter())
.map(|(p, y)| (p - y).powi(2))
.sum::<f32>()
/ (self.ys.len() as f32);
fitness.push(mse);
}
Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
}
fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
rlevo_core::objective::ObjectiveSense::Minimize
}
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn cgp_reduces_error_on_square_plus_one() {
let device = Default::default();
let params = CgpConfig::default_for(1);
let landscape = SymRegression::new(params.clone());
let initial_error = {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(123);
let genome = CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(
¶ms, &mut rng,
);
let inputs: Vec<Vec<f32>> = landscape.xs.iter().map(|&x| vec![x]).collect();
let preds = evaluate_cgp(&genome, ¶ms, &inputs);
preds
.iter()
.zip(landscape.ys.iter())
.map(|(p, y)| (p - y).powi(2))
.sum::<f32>()
/ (landscape.ys.len() as f32)
};
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
CartesianGeneticProgramming::<TestBackend>::new(),
params,
landscape,
21,
device,
2000,
)
.expect("valid params");
harness.reset();
loop {
if harness.step(()).done {
break;
}
}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(
best < initial_error,
"CGP did not improve: best={best} initial={initial_error}"
);
assert!(best < 0.2, "expected MSE < 0.2 but got {best}");
}
}