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;
102
103 #[must_use]
105 pub fn genome_len(&self) -> usize {
106 self.rows * self.cols * Self::GENES_PER_NODE + Self::OUTPUT_GENES
107 }
108}
109
110#[derive(Debug, Clone)]
112pub struct CgpState<B: Backend> {
113 pub parent: Tensor<B, 2, Int>,
115 pub parent_fitness: f32,
117 pub best_genome: Option<Tensor<B, 2, Int>>,
119 pub best_fitness: f32,
121 pub generation: usize,
123}
124
125#[derive(Debug, Clone, Copy, Default)]
139pub struct CartesianGeneticProgramming<B: Backend> {
140 _backend: PhantomData<fn() -> B>,
141}
142
143impl<B: Backend> CartesianGeneticProgramming<B> {
144 #[must_use]
146 pub fn new() -> Self {
147 Self {
148 _backend: PhantomData,
149 }
150 }
151
152 fn sample_initial_genome(params: &CgpConfig, rng: &mut dyn Rng) -> Vec<i64> {
153 let mut genome = Vec::with_capacity(params.genome_len());
154 for col in 0..params.cols {
155 for _row in 0..params.rows {
156 #[allow(clippy::cast_possible_wrap)]
157 let func = rng.random_range(0..NUM_FUNCTIONS as i64);
158 let (inp0, inp1) = sample_input_pair(col, params, rng);
159 genome.push(func);
160 genome.push(inp0);
161 genome.push(inp1);
162 }
163 }
164 let max_node_idx = params.n_inputs + params.rows * params.cols;
166 #[allow(clippy::cast_possible_wrap)]
167 genome.push(rng.random_range(0..max_node_idx as i64));
168 genome
169 }
170
171 fn genome_to_host(genome: &Tensor<B, 2, Int>) -> Vec<i64> {
172 genome
173 .clone()
174 .into_data()
175 .into_vec::<i32>()
176 .unwrap_or_default()
177 .into_iter()
178 .map(i64::from)
179 .collect()
180 }
181}
182
183fn sample_input_pair(col: usize, params: &CgpConfig, rng: &mut dyn Rng) -> (i64, i64) {
184 let min_col = col.saturating_sub(params.levels_back);
185 let node_indices_start = params.n_inputs + min_col * params.rows;
186 let node_indices_end = params.n_inputs + col * params.rows;
187 let max = node_indices_end.max(params.n_inputs);
188 let input_count = params.n_inputs
190 + (max - params.n_inputs)
191 .saturating_sub(node_indices_start.saturating_sub(params.n_inputs));
192 let pool: Vec<i64> = (0..params.n_inputs)
193 .chain(node_indices_start..node_indices_end)
194 .map(|i| {
195 #[allow(clippy::cast_possible_wrap)]
196 let v = i as i64;
197 v
198 })
199 .collect();
200 let pool = if pool.is_empty() {
201 #[allow(clippy::cast_possible_wrap)]
202 (0..params.n_inputs as i64).collect()
203 } else {
204 pool
205 };
206 let _ = input_count;
207 let pick = |rng: &mut dyn Rng| -> i64 {
208 let idx = rng.random_range(0..pool.len());
209 pool[idx]
210 };
211 (pick(rng), pick(rng))
212}
213
214fn mutate_genome(genome: &mut [i64], params: &CgpConfig, rng: &mut dyn Rng) {
215 let genes_per_node = CgpConfig::GENES_PER_NODE;
216 let node_genes = params.rows * params.cols * genes_per_node;
217 for (gene_idx, gene) in genome.iter_mut().enumerate() {
218 if rng.random::<f32>() >= params.mutation_rate {
219 continue;
220 }
221 if gene_idx < node_genes {
222 let within = gene_idx % genes_per_node;
223 let node_idx = gene_idx / genes_per_node;
224 let col = node_idx / params.rows;
225 if within == 0 {
226 #[allow(clippy::cast_possible_wrap)]
228 {
229 *gene = rng.random_range(0..NUM_FUNCTIONS as i64);
230 }
231 } else {
232 let (new0, new1) = sample_input_pair(col, params, rng);
233 *gene = if within == 1 { new0 } else { new1 };
234 }
235 } else {
236 let max_node_idx = params.n_inputs + params.rows * params.cols;
238 #[allow(clippy::cast_possible_wrap)]
239 {
240 *gene = rng.random_range(0..max_node_idx as i64);
241 }
242 }
243 }
244}
245
246#[must_use]
261pub fn evaluate_cgp(genome: &[i64], params: &CgpConfig, inputs: &[Vec<f32>]) -> Vec<f32> {
262 let node_count = params.rows * params.cols;
263 let n_inputs = params.n_inputs;
264 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
265 let output_idx = genome[genome.len() - 1] as usize;
266
267 let mut outputs = Vec::with_capacity(inputs.len());
268 let mut buf = vec![0.0_f32; n_inputs + node_count];
269
270 for sample in inputs {
271 for (i, v) in sample.iter().enumerate() {
272 buf[i] = *v;
273 }
274 for node in 0..node_count {
275 let base = node * 3;
276 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
277 let func = genome[base] as usize;
278 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
279 let a_idx = genome[base + 1] as usize;
280 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
281 let b_idx = genome[base + 2] as usize;
282 let a = buf[a_idx.min(buf.len() - 1)];
283 let b = buf[b_idx.min(buf.len() - 1)];
284 let v = match func {
285 0 => a + b,
286 1 => a - b,
287 2 => a * b,
288 3 => {
289 if b.abs() < 1e-6 {
290 a
291 } else {
292 a / b
293 }
294 }
295 4 => a.sin(),
296 5 => a.cos(),
297 6 => a.tanh(),
298 7 => 1.0,
299 _ => 0.0,
300 };
301 buf[n_inputs + node] = if v.is_finite() { v } else { 0.0 };
302 }
303 outputs.push(buf[output_idx.min(buf.len() - 1)]);
304 }
305
306 outputs
307}
308
309impl<B: Backend> Strategy<B> for CartesianGeneticProgramming<B>
310where
311 B::Device: Clone,
312{
313 type Params = CgpConfig;
314 type State = CgpState<B>;
315 type Genome = Tensor<B, 2, Int>;
316
317 fn init(&self, params: &CgpConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> CgpState<B> {
321 let genome_vec = Self::sample_initial_genome(params, rng);
322 let parent = Tensor::<B, 2, Int>::from_data(
323 TensorData::new(genome_vec, [1, params.genome_len()]),
324 device,
325 );
326 CgpState {
327 parent,
328 parent_fitness: f32::INFINITY,
329 best_genome: None,
330 best_fitness: f32::INFINITY,
331 generation: 0,
332 }
333 }
334
335 fn ask(
343 &self,
344 params: &CgpConfig,
345 state: &CgpState<B>,
346 rng: &mut dyn Rng,
347 device: &<B as burn::tensor::backend::BackendTypes>::Device,
348 ) -> (Tensor<B, 2, Int>, CgpState<B>) {
349 if !state.parent_fitness.is_finite() {
351 return (state.parent.clone(), state.clone());
352 }
353
354 let mut mut_rng = seed_stream(
355 rng.next_u64(),
356 state.generation as u64,
357 SeedPurpose::Mutation,
358 );
359 let parent_vec = Self::genome_to_host(&state.parent);
360 let mut offspring_genomes: Vec<i64> =
361 Vec::with_capacity(params.lambda * params.genome_len());
362 for _ in 0..params.lambda {
363 let mut child = parent_vec.clone();
364 mutate_genome(&mut child, params, &mut mut_rng);
365 offspring_genomes.extend(child);
366 }
367 #[allow(clippy::cast_possible_truncation)]
368 let offspring_genomes_i32: Vec<i32> =
369 offspring_genomes.into_iter().map(|v| v as i32).collect();
370 let offspring = Tensor::<B, 2, Int>::from_data(
371 TensorData::new(offspring_genomes_i32, [params.lambda, params.genome_len()]),
372 device,
373 );
374 (offspring, state.clone())
375 }
376
377 fn tell(
387 &self,
388 _params: &CgpConfig,
389 offspring: Tensor<B, 2, Int>,
390 fitness: Tensor<B, 1>,
391 mut state: CgpState<B>,
392 _rng: &mut dyn Rng,
393 ) -> (CgpState<B>, StrategyMetrics) {
394 let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
395
396 if !state.parent_fitness.is_finite() {
397 state.parent_fitness = fitness_host[0];
399 state.generation += 1;
400 update_best(&mut state, &offspring, &fitness_host);
401 let m = StrategyMetrics::from_host_fitness(
402 state.generation,
403 &fitness_host,
404 state.best_fitness,
405 );
406 state.best_fitness = m.best_fitness_ever;
407 return (state, m);
408 }
409
410 let best_off_idx = fitness_host
414 .iter()
415 .enumerate()
416 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
417 .map_or(0, |(i, _)| i);
418 let best_off_fit = fitness_host[best_off_idx];
419 if best_off_fit <= state.parent_fitness {
420 let device = offspring.device();
421 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
422 let idx = Tensor::<B, 1, Int>::from_data(
423 TensorData::new(vec![best_off_idx as i32], [1]),
424 &device,
425 );
426 state.parent = offspring.clone().select(0, idx);
427 state.parent_fitness = best_off_fit;
428 }
429
430 state.generation += 1;
431 update_best(&mut state, &offspring, &fitness_host);
432 let m =
433 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
434 state.best_fitness = m.best_fitness_ever;
435 (state, m)
436 }
437
438 fn best(&self, state: &CgpState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
441 state
442 .best_genome
443 .as_ref()
444 .map(|g| (g.clone(), state.best_fitness))
445 }
446}
447
448fn update_best<B: Backend>(state: &mut CgpState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
449 if fitness.is_empty() {
450 return;
451 }
452 let mut best_idx = 0usize;
453 let mut best_f = fitness[0];
454 for (i, &f) in fitness.iter().enumerate().skip(1) {
455 if f < best_f {
456 best_f = f;
457 best_idx = i;
458 }
459 }
460 if best_f < state.best_fitness {
461 let device = pop.device();
462 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
463 let idx =
464 Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i32], [1]), &device);
465 state.best_genome = Some(pop.clone().select(0, idx));
466 state.best_fitness = best_f;
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::fitness::BatchFitnessFn;
474 use crate::strategy::EvolutionaryHarness;
475 use burn::backend::Flex;
476 type TestBackend = Flex;
477
478 struct SymRegression {
480 params: CgpConfig,
481 xs: Vec<f32>,
482 ys: Vec<f32>,
483 }
484
485 impl SymRegression {
486 #[allow(clippy::cast_precision_loss)]
487 fn new(params: CgpConfig) -> Self {
488 let xs: Vec<f32> = (0..20).map(|i| -1.0 + 2.0 * (i as f32) / 19.0).collect();
489 let ys: Vec<f32> = xs.iter().map(|x| x * x + 1.0).collect();
490 Self { params, xs, ys }
491 }
492 }
493
494 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for SymRegression {
495 #[allow(clippy::cast_precision_loss)]
496 fn evaluate_batch(
497 &mut self,
498 population: &Tensor<B, 2, Int>,
499 device: &<B as burn::tensor::backend::BackendTypes>::Device,
500 ) -> Tensor<B, 1> {
501 let pop_size = population.dims()[0];
502 let data: Vec<i64> = population
503 .clone()
504 .into_data()
505 .into_vec::<i32>()
506 .unwrap()
507 .into_iter()
508 .map(i64::from)
509 .collect();
510 let gl = self.params.genome_len();
511 let inputs: Vec<Vec<f32>> = self.xs.iter().map(|&x| vec![x]).collect();
512 let mut fitness = Vec::with_capacity(pop_size);
513 for row in 0..pop_size {
514 let genome = &data[row * gl..(row + 1) * gl];
515 let preds = evaluate_cgp(genome, &self.params, &inputs);
516 let mse: f32 = preds
517 .iter()
518 .zip(self.ys.iter())
519 .map(|(p, y)| (p - y).powi(2))
520 .sum::<f32>()
521 / (self.ys.len() as f32);
522 fitness.push(mse);
523 }
524 Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
525 }
526 }
527
528 #[test]
529 #[allow(clippy::cast_precision_loss)]
530 fn cgp_reduces_error_on_square_plus_one() {
531 let device = Default::default();
532 let params = CgpConfig::default_for(1);
533 let landscape = SymRegression::new(params.clone());
534 let initial_error = {
535 use rand::SeedableRng;
537 let mut rng = rand::rngs::StdRng::seed_from_u64(123);
538 let genome = CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(
539 ¶ms, &mut rng,
540 );
541 let inputs: Vec<Vec<f32>> = landscape.xs.iter().map(|&x| vec![x]).collect();
542 let preds = evaluate_cgp(&genome, ¶ms, &inputs);
543 preds
544 .iter()
545 .zip(landscape.ys.iter())
546 .map(|(p, y)| (p - y).powi(2))
547 .sum::<f32>()
548 / (landscape.ys.len() as f32)
549 };
550
551 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
552 CartesianGeneticProgramming::<TestBackend>::new(),
553 params,
554 landscape,
555 21,
556 device,
557 2000,
558 );
559 harness.reset();
560 loop {
561 if harness.step(()).done {
562 break;
563 }
564 }
565 let best = harness.latest_metrics().unwrap().best_fitness_ever;
566 assert!(
568 best < initial_error,
569 "CGP did not improve: best={best} initial={initial_error}"
570 );
571 assert!(best < 0.2, "expected MSE < 0.2 but got {best}");
573 }
574}