1use 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
62pub const FUNCTION_ARITIES: [usize; 8] = [2, 2, 2, 2, 1, 1, 1, 0];
64pub const NUM_FUNCTIONS: usize = FUNCTION_ARITIES.len();
66
67#[derive(Debug, Clone)]
69pub struct CgpConfig {
70 pub lambda: usize,
72 pub n_inputs: usize,
74 pub rows: usize,
76 pub cols: usize,
78 pub mutation_rate: Probability,
81 pub levels_back: usize,
84}
85
86impl CgpConfig {
87 #[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; 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 pub const GENES_PER_NODE: usize = 3;
110 pub const OUTPUT_GENES: usize = 1;
113
114 #[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 config::at_least(C, "levels_back", self.levels_back, 1)?;
131 Ok(())
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct CgpState<B: Backend> {
138 pub parent: Tensor<B, 2, Int>,
140 pub parent_fitness: Option<f32>,
149 pub best_genome: Option<Tensor<B, 2, Int>>,
151 pub best_fitness: f32,
153 pub generation: usize,
155}
156
157#[derive(Debug, Clone, Copy, Default)]
171pub struct CartesianGeneticProgramming<B: Backend> {
172 _backend: PhantomData<fn() -> B>,
173}
174
175impl<B: Backend> CartesianGeneticProgramming<B> {
176 #[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 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 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 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 #[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 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#[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#[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 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 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 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 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 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 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 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 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 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 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 #[test]
594 fn sample_input_pair_empty_pool_returns_benign_zero() {
595 use rand::SeedableRng;
596 use rand::rngs::StdRng;
597
598 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 #[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 let mut params = CgpConfig::default_for(1);
621 params.lambda = 0;
622 let mut rng = StdRng::seed_from_u64(11);
623 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(¶ms, 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 #[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(¶ms, &mut rng, &device);
666
667 let (boot, next) = strategy.ask(¶ms, &state, &mut rng, &device);
669 assert_eq!(boot.dims()[0], 1, "bootstrap ask returns the single parent");
670
671 let neg_inf = Tensor::<TestBackend, 1>::from_data(
675 TensorData::new(vec![f32::NEG_INFINITY], [1]),
676 &device,
677 );
678 let (state1, _) = strategy.tell(¶ms, 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 let (offspring, _) = strategy.ask(¶ms, &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 #[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 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 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 #[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(¶ms, &mut rng);
740 let before = genome.clone();
741
742 params.mutation_rate = Probability::new(0.0);
743 mutate_genome(&mut genome, ¶ms, &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 #[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 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(¶ms, &mut rng);
800 check(&genome);
801
802 let mut mutated = genome.clone();
804 params.mutation_rate = Probability::new(1.0);
805 mutate_genome(&mut mutated, ¶ms, &mut rng);
806 assert_eq!(mutated.len(), genome.len(), "mutation preserves length");
807 check(&mutated);
808 }
809
810 #[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 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, ¶ms, &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 #[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(¶ms, &mut rng, &device);
846 assert!(
847 strategy.best(&state).is_none(),
848 "best must be None before the first tell"
849 );
850 }
851
852 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 use rand::SeedableRng;
915 let mut rng = rand::rngs::StdRng::seed_from_u64(123);
916 let genome = CartesianGeneticProgramming::<TestBackend>::sample_initial_genome(
917 ¶ms, &mut rng,
918 );
919 let inputs: Vec<Vec<f32>> = landscape.xs.iter().map(|&x| vec![x]).collect();
920 let preds = evaluate_cgp(&genome, ¶ms, &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 assert!(
947 best < initial_error,
948 "CGP did not improve: best={best} initial={initial_error}"
949 );
950 assert!(best < 0.2, "expected MSE < 0.2 but got {best}");
952 }
953}