1use 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
52pub const FUNCTION_ARITIES: [usize; 8] = [2, 2, 2, 2, 1, 1, 1, 0];
54pub const NUM_FUNCTIONS: usize = FUNCTION_ARITIES.len();
56
57#[derive(Debug, Clone)]
59pub struct CgpConfig {
60 pub lambda: usize,
62 pub n_inputs: usize,
64 pub rows: usize,
66 pub cols: usize,
68 pub mutation_rate: f32,
70 pub levels_back: usize,
73}
74
75impl CgpConfig {
76 #[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; 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 pub const GENES_PER_NODE: usize = 3;
99 pub const OUTPUT_GENES: usize = 1;
101
102 #[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#[derive(Debug, Clone)]
111pub struct CgpState<B: Backend> {
112 pub parent: Tensor<B, 2, Int>,
114 pub parent_fitness: f32,
116 pub best_genome: Option<Tensor<B, 2, Int>>,
118 pub best_fitness: f32,
120 pub generation: usize,
122}
123
124#[derive(Debug, Clone, Copy, Default)]
138pub struct CartesianGeneticProgramming<B: Backend> {
139 _backend: PhantomData<fn() -> B>,
140}
141
142impl<B: Backend> CartesianGeneticProgramming<B> {
143 #[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 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 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 #[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 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#[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 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 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 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 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 use rand::SeedableRng;
502 let mut rng = rand::rngs::StdRng::seed_from_u64(123);
503 let genome = CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(
504 ¶ms, &mut rng,
505 );
506 let inputs: Vec<Vec<f32>> = landscape.xs.iter().map(|&x| vec![x]).collect();
507 let preds = evaluate_cgp(&genome, ¶ms, &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 assert!(
533 best < initial_error,
534 "CGP did not improve: best={best} initial={initial_error}"
535 );
536 assert!(best < 0.2, "expected MSE < 0.2 but got {best}");
538 }
539}