1use std::marker::PhantomData;
4
5use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
6use rand::{Rng, RngExt};
7use rayon::prelude::*;
8use rlevo_core::config::{self, ConfigError};
9
10use crate::fitness::BatchFitnessFn;
11use crate::function_set::{FunctionSet, Symbol};
12use crate::rng::{SeedPurpose, seed_stream};
13use crate::strategy::{Strategy, StrategyMetrics};
14
15use super::alphabet::Alphabet;
16use super::config::GepConfig;
17use super::decode::{GenotypePhenotypeMap, GepDecoder};
18use super::operators::{
19 is_transposition, one_point_crossover, point_mutation, ris_transposition, two_point_crossover,
20};
21
22#[derive(Debug, Clone)]
28pub struct GepState<B: Backend> {
29 population: Tensor<B, 2, Int>,
31 fitnesses: Vec<f32>,
35 best_genome: Option<Tensor<B, 2, Int>>,
37 best_fitness: f32,
39 generation: usize,
41}
42
43impl<B: Backend> GepState<B> {
44 pub fn try_new(
53 population: Tensor<B, 2, Int>,
54 fitnesses: Vec<f32>,
55 best_genome: Option<Tensor<B, 2, Int>>,
56 best_fitness: f32,
57 generation: usize,
58 ) -> Result<Self, ConfigError> {
59 let pop = population.dims()[0];
60 config::nonzero("GepState", "pop_size", pop)?;
61 if !fitnesses.is_empty() && fitnesses.len() != pop {
62 return Err(ConfigError {
63 config: "GepState",
64 field: "fitnesses",
65 kind: rlevo_core::config::ConstraintKind::Custom(
66 "fitness cache length must equal pop_size",
67 ),
68 });
69 }
70 Ok(Self {
71 population,
72 fitnesses,
73 best_genome,
74 best_fitness,
75 generation,
76 })
77 }
78
79 #[must_use]
81 pub fn population(&self) -> &Tensor<B, 2, Int> {
82 &self.population
83 }
84
85 #[must_use]
87 pub fn fitnesses(&self) -> &[f32] {
88 &self.fitnesses
89 }
90
91 #[must_use]
94 pub fn best_genome(&self) -> Option<&Tensor<B, 2, Int>> {
95 self.best_genome.as_ref()
96 }
97
98 #[must_use]
100 pub fn best_fitness(&self) -> f32 {
101 self.best_fitness
102 }
103
104 #[must_use]
106 pub fn generation(&self) -> usize {
107 self.generation
108 }
109}
110
111#[derive(Debug, Clone)]
126pub struct GepStrategy<B: Backend, F: FunctionSet> {
127 alphabet: Alphabet<F>,
128 _backend: PhantomData<fn() -> B>,
129}
130
131impl<B: Backend, F: FunctionSet> GepStrategy<B, F> {
132 #[must_use]
134 pub fn new(alphabet: Alphabet<F>) -> Self {
135 Self {
136 alphabet,
137 _backend: PhantomData,
138 }
139 }
140
141 #[must_use]
143 pub fn alphabet(&self) -> &Alphabet<F> {
144 &self.alphabet
145 }
146
147 fn sample_chromosome(&self, cfg: &GepConfig, rng: &mut dyn Rng) -> Vec<Symbol> {
150 let mut g = Vec::with_capacity(cfg.genome_len());
151 for _ in 0..cfg.head_len {
152 g.push(self.alphabet.sample_head_symbol(rng));
153 }
154 for _ in 0..cfg.tail_len {
155 g.push(self.alphabet.sample_tail_symbol(rng));
156 }
157 g
158 }
159}
160
161fn tensor_to_rows<B: Backend>(pop: &Tensor<B, 2, Int>, genome_len: usize) -> Vec<Vec<Symbol>> {
163 let flat: Vec<i32> = pop
164 .clone()
165 .into_data()
166 .into_vec::<i32>()
167 .expect("genome tensor must be readable as i32");
169 flat.chunks(genome_len)
170 .map(|row| row.iter().map(|&v| Symbol::from_raw(v)).collect())
171 .collect()
172}
173
174fn rows_to_tensor<B: Backend>(
176 rows: &[Vec<Symbol>],
177 genome_len: usize,
178 device: &<B as burn::tensor::backend::BackendTypes>::Device,
179) -> Tensor<B, 2, Int> {
180 let pop_size = rows.len();
181 let mut flat: Vec<i32> = Vec::with_capacity(pop_size * genome_len);
182 for row in rows {
183 flat.extend(row.iter().map(|s| s.value()));
184 }
185 Tensor::<B, 2, Int>::from_data(TensorData::new(flat, [pop_size, genome_len]), device)
186}
187
188fn roulette_select(fitnesses: &[f32], k: usize, rng: &mut dyn Rng) -> Vec<usize> {
200 const EPS: f32 = 1e-6;
201 let n = fitnesses.len();
202 let min_finite = fitnesses
203 .iter()
204 .copied()
205 .filter(|f| f.is_finite())
206 .fold(f32::INFINITY, f32::min);
207 let weights: Vec<f32> = fitnesses
208 .iter()
209 .map(|&f| {
210 if f.is_finite() && min_finite.is_finite() {
211 (f - min_finite).max(0.0) + EPS
212 } else {
213 0.0
214 }
215 })
216 .collect();
217 let total: f32 = weights.iter().sum();
218
219 let mut out = Vec::with_capacity(k);
220 if total <= 0.0 || !total.is_finite() {
221 for _ in 0..k {
222 out.push(rng.random_range(0..n));
223 }
224 return out;
225 }
226 for _ in 0..k {
227 let mut r = rng.random::<f32>() * total;
228 let mut chosen = n - 1;
229 for (i, &w) in weights.iter().enumerate() {
230 r -= w;
231 if r <= 0.0 {
232 chosen = i;
233 break;
234 }
235 }
236 out.push(chosen);
237 }
238 out
239}
240
241fn update_best<B: Backend>(state: &mut GepState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
242 if fitness.is_empty() {
243 return;
244 }
245 let mut best_idx = 0usize;
246 let mut best_f = fitness[0];
247 for (i, &f) in fitness.iter().enumerate().skip(1) {
248 if f > best_f {
249 best_f = f;
250 best_idx = i;
251 }
252 }
253 if best_f > state.best_fitness {
254 let device = pop.device();
255 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
256 let idx =
257 Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i32], [1]), &device);
258 state.best_genome = Some(pop.clone().select(0, idx));
259 state.best_fitness = best_f;
260 }
261}
262
263impl<B: Backend, F: FunctionSet> Strategy<B> for GepStrategy<B, F>
264where
265 B::Device: Clone,
266{
267 type Params = GepConfig;
268 type State = GepState<B>;
269 type Genome = Tensor<B, 2, Int>;
270
271 fn init(
275 &self,
276 params: &GepConfig,
277 rng: &mut dyn Rng,
278 device: &<B as burn::tensor::backend::BackendTypes>::Device,
279 ) -> GepState<B> {
280 debug_assert_eq!(
281 self.alphabet.n_vars, params.n_vars,
282 "GepStrategy: alphabet/config variable counts must agree"
283 );
284 let genome_len = params.genome_len();
285 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
286 let rows: Vec<Vec<Symbol>> = (0..params.pop_size)
287 .map(|_| self.sample_chromosome(params, &mut stream))
288 .collect();
289 let population = rows_to_tensor::<B>(&rows, genome_len, device);
290 GepState {
291 population,
292 fitnesses: Vec::new(),
293 best_genome: None,
294 best_fitness: f32::NEG_INFINITY,
295 generation: 0,
296 }
297 }
298
299 fn ask(
308 &self,
309 params: &GepConfig,
310 state: &GepState<B>,
311 rng: &mut dyn Rng,
312 device: &<B as burn::tensor::backend::BackendTypes>::Device,
313 ) -> (Tensor<B, 2, Int>, GepState<B>) {
314 if state.fitnesses.is_empty() {
316 return (state.population.clone(), state.clone());
317 }
318
319 let genome_len = params.genome_len();
320 let head_len = params.head_len;
321 let pop_size = params.pop_size;
322 let parents = tensor_to_rows::<B>(&state.population, genome_len);
323
324 let base = rng.next_u64();
326 let generation = state.generation as u64;
327 let mut sel_rng = seed_stream(base, generation, SeedPurpose::Selection);
328 let mut xover_rng = seed_stream(base, generation, SeedPurpose::Crossover);
329 let mut trans_rng = seed_stream(base, generation, SeedPurpose::Transposition);
330 let mut mut_rng = seed_stream(base, generation, SeedPurpose::Mutation);
331
332 let chosen = roulette_select(&state.fitnesses, pop_size, &mut sel_rng);
334 let mut offspring: Vec<Vec<Symbol>> =
335 chosen.into_iter().map(|i| parents[i].clone()).collect();
336
337 for pair in offspring.chunks_mut(2) {
339 if pair.len() < 2 {
340 break;
341 }
342 let (left, right) = pair.split_at_mut(1);
343 if xover_rng.random::<f32>() < params.crossover_1p_rate.get() {
344 one_point_crossover(&mut left[0], &mut right[0], &mut xover_rng);
345 }
346 if xover_rng.random::<f32>() < params.crossover_2p_rate.get() {
347 two_point_crossover(&mut left[0], &mut right[0], &mut xover_rng);
348 }
349 }
350
351 for child in &mut offspring {
353 if trans_rng.random::<f32>() < params.is_transpose_rate.get() {
354 is_transposition(child, head_len, &mut trans_rng);
355 }
356 if trans_rng.random::<f32>() < params.ris_transpose_rate.get() {
357 ris_transposition(child, head_len, &self.alphabet, &mut trans_rng);
358 }
359 point_mutation(
360 child,
361 head_len,
362 &self.alphabet,
363 params.mutation_rate.get(),
364 &mut mut_rng,
365 );
366 }
367
368 if let Some(best) = &state.best_genome {
370 let best_rows = tensor_to_rows::<B>(best, genome_len);
371 if let Some(elite) = best_rows.into_iter().next() {
372 offspring[0] = elite;
373 }
374 }
375
376 let population = rows_to_tensor::<B>(&offspring, genome_len, device);
377 (population, state.clone())
378 }
379
380 fn tell(
384 &self,
385 _params: &GepConfig,
386 population: Tensor<B, 2, Int>,
387 fitness: Tensor<B, 1>,
388 mut state: GepState<B>,
389 _rng: &mut dyn Rng,
390 ) -> (GepState<B>, StrategyMetrics) {
391 let fitness_host = fitness
392 .into_data()
393 .into_vec::<f32>()
394 .expect("fitness tensor must be readable as f32");
395
396 update_best(&mut state, &population, &fitness_host);
397 state.population = population;
398 state.generation += 1;
399
400 let metrics =
401 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
402 state.best_fitness = metrics.best_fitness_ever();
403 state.fitnesses = fitness_host;
405 (state, metrics)
406 }
407
408 fn best(&self, state: &GepState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
409 state
410 .best_genome
411 .as_ref()
412 .map(|g| (g.clone(), state.best_fitness))
413 }
414}
415
416#[derive(Debug, Clone)]
425pub struct GepSymRegression<F: FunctionSet> {
426 alphabet: Alphabet<F>,
427 genome_len: usize,
428 inputs: Vec<Vec<f32>>,
429 targets: Vec<f32>,
430}
431
432impl<F: FunctionSet> GepSymRegression<F> {
433 #[must_use]
448 pub fn new(
449 alphabet: Alphabet<F>,
450 genome_len: usize,
451 inputs: Vec<Vec<f32>>,
452 targets: Vec<f32>,
453 ) -> Self {
454 assert!(
455 !inputs.is_empty(),
456 "GepSymRegression: dataset must be non-empty (empty inputs give NaN fitness)"
457 );
458 assert_eq!(
459 inputs.len(),
460 targets.len(),
461 "GepSymRegression: inputs and targets must have equal length"
462 );
463 let n_vars = alphabet.n_vars;
464 assert!(
465 inputs.iter().all(|row| row.len() == n_vars),
466 "GepSymRegression: every input row must have exactly n_vars = {n_vars} entries"
467 );
468 Self {
469 alphabet,
470 genome_len,
471 inputs,
472 targets,
473 }
474 }
475}
476
477impl<B: Backend, F: FunctionSet> BatchFitnessFn<B, Tensor<B, 2, Int>> for GepSymRegression<F> {
478 fn evaluate_batch(
479 &mut self,
480 population: &Tensor<B, 2, Int>,
481 device: &<B as burn::tensor::backend::BackendTypes>::Device,
482 ) -> Tensor<B, 1> {
483 let rows = tensor_to_rows::<B>(population, self.genome_len);
484 let pop_size = rows.len();
485 #[allow(clippy::cast_precision_loss)]
486 let n_points = self.targets.len() as f32;
487
488 let fitness: Vec<f32> = rows
489 .par_iter()
490 .map(|genome| {
491 let tree = GepDecoder.decode(&self.alphabet, genome);
492 let mut sse = 0.0f32;
493 for (input, &target) in self.inputs.iter().zip(self.targets.iter()) {
494 let pred = tree.eval(&self.alphabet, input);
495 let err = pred - target;
496 sse += err * err;
497 }
498 sse / n_points
499 })
500 .collect();
501
502 Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
503 }
504
505 fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
506 rlevo_core::objective::ObjectiveSense::Minimize
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::function_set::ArithmeticFunctionSet;
515 use crate::strategy::EvolutionaryHarness;
516 use burn::backend::Flex;
517
518 type TestBackend = Flex;
519
520 #[test]
523 fn roulette_all_equal_fitness_stays_in_range() {
524 let mut rng = seed_stream(31, 0, SeedPurpose::Selection);
525 let fits: Vec<f32> = vec![1.0; 5];
526 let picks: Vec<usize> = roulette_select(&fits, 10, &mut rng);
527 assert_eq!(picks.len(), 10);
528 assert!(picks.iter().all(|&i| i < 5));
529 }
530
531 #[test]
536 fn roulette_all_nan_falls_back_to_uniform() {
537 const N: usize = 4000;
538 let mut rng = seed_stream(32, 0, SeedPurpose::Selection);
539 let fits: Vec<f32> = vec![f32::NAN; 4];
540 let picks: Vec<usize> = roulette_select(&fits, N, &mut rng);
541 assert_eq!(picks.len(), N);
542 assert!(picks.iter().all(|&i| i < 4));
543
544 let mut counts = [0usize; 4];
545 for &i in &picks {
546 counts[i] += 1;
547 }
548 let expected: usize = N / 4;
552 let low = expected - expected * 2 / 5;
553 let high = expected + expected * 2 / 5;
554 for (idx, &c) in counts.iter().enumerate() {
555 assert!(
556 (low..=high).contains(&c),
557 "index {idx} drawn {c} times, outside uniform band [{low}, {high}]"
558 );
559 }
560 }
561
562 #[test]
564 fn roulette_single_element_always_picks_zero() {
565 let mut rng = seed_stream(33, 0, SeedPurpose::Selection);
566 let picks: Vec<usize> = roulette_select(&[3.5], 6, &mut rng);
567 assert_eq!(picks, vec![0usize; 6]);
568 }
569
570 #[test]
573 fn roulette_empty_with_zero_k_is_empty() {
574 let mut rng = seed_stream(34, 0, SeedPurpose::Selection);
575 let picks: Vec<usize> = roulette_select(&[], 0, &mut rng);
576 assert!(picks.is_empty());
577 }
578
579 #[test]
583 fn roulette_negative_fitness_favours_highest() {
584 let mut rng = seed_stream(35, 0, SeedPurpose::Selection);
585 let fits: Vec<f32> = vec![-100.0, -50.0, -1.0, -75.0];
587 let picks: Vec<usize> = roulette_select(&fits, 2000, &mut rng);
588 assert!(picks.iter().all(|&i| i < 4));
589 let count_best: usize = picks.iter().filter(|&&i| i == 2).count();
590 let count_worst: usize = picks.iter().filter(|&&i| i == 0).count();
591 assert!(
592 count_best > count_worst,
593 "highest fitness ({count_best}) should be picked more than lowest ({count_worst})"
594 );
595 }
596
597 #[test]
598 fn try_new_checks_fitness_length() {
599 let device = Default::default();
600 let pop = Tensor::<TestBackend, 2, Int>::zeros([3, 4], &device);
601 assert!(GepState::try_new(pop.clone(), vec![], None, f32::MIN, 0).is_ok());
602 assert!(GepState::try_new(pop.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
603 assert!(GepState::try_new(pop, vec![1.0; 2], None, 1.0, 1).is_err());
604 }
605
606 fn alphabet(n_vars: usize) -> Alphabet<ArithmeticFunctionSet> {
607 Alphabet::new(ArithmeticFunctionSet, n_vars, vec![])
608 }
609
610 fn run_gep(
612 n_vars: usize,
613 inputs: Vec<Vec<f32>>,
614 targets: Vec<f32>,
615 seed: u64,
616 max_gens: usize,
617 ) -> f32 {
618 let device = Default::default();
619 let cfg = GepConfig::new(7, 2, n_vars, 100).unwrap();
620 let genome_len = cfg.genome_len();
621 let strategy = GepStrategy::<TestBackend, _>::new(alphabet(n_vars));
622 let fitness = GepSymRegression::new(alphabet(n_vars), genome_len, inputs, targets);
623 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
624 strategy, cfg, fitness, seed, device, max_gens,
625 )
626 .expect("valid params");
627 harness.reset();
628 loop {
629 if harness.step(()).done {
630 break;
631 }
632 }
633 harness.latest_metrics().unwrap().best_fitness_ever()
634 }
635
636 #[test]
638 #[allow(clippy::cast_precision_loss)]
639 fn converges_on_quadratic() {
640 let xs: Vec<f32> = (0..20).map(|i| -1.0 + 2.0 * (i as f32) / 19.0).collect();
641 let inputs: Vec<Vec<f32>> = xs.iter().map(|&x| vec![x]).collect();
642 let targets: Vec<f32> = xs.iter().map(|&x| x * x + x + 1.0).collect();
643 let best = run_gep(1, inputs, targets, 11, 500);
644 assert!(best <= 0.01, "expected MSE <= 0.01, got {best}");
645 }
646
647 #[test]
649 #[allow(clippy::cast_precision_loss)]
650 fn converges_on_sin_times_x() {
651 let xs: Vec<f32> = (0..20).map(|i| -3.0 + 6.0 * (i as f32) / 19.0).collect();
652 let inputs: Vec<Vec<f32>> = xs.iter().map(|&x| vec![x]).collect();
653 let targets: Vec<f32> = xs.iter().map(|&x| x.sin() * x).collect();
654 let best = run_gep(1, inputs, targets, 7, 500);
655 assert!(best <= 0.01, "expected MSE <= 0.01, got {best}");
656 }
657
658 #[test]
660 #[should_panic(expected = "dataset must be non-empty")]
661 fn test_gep_sym_regression_rejects_empty_dataset() {
662 let _ = GepSymRegression::new(alphabet(1), 15, Vec::new(), Vec::new());
663 }
664
665 #[test]
668 #[should_panic(expected = "every input row must have exactly n_vars")]
669 fn test_gep_sym_regression_rejects_mismatched_row_width() {
670 let _ = GepSymRegression::new(alphabet(2), 15, vec![vec![1.0]], vec![0.0]);
672 }
673
674 #[test]
676 fn test_gep_sym_regression_valid_dataset_is_finite() {
677 let device = Default::default();
678 let cfg = GepConfig::new(7, 2, 1, 4).unwrap();
679 let genome_len = cfg.genome_len();
680 let mut fitness = GepSymRegression::new(
681 alphabet(1),
682 genome_len,
683 vec![vec![0.5], vec![-0.5]],
684 vec![0.25, 0.25],
685 );
686 let pop = Tensor::<TestBackend, 2, Int>::from_data(
691 TensorData::new(vec![8i32; 4 * genome_len], [4, genome_len]),
692 &device,
693 );
694 let scores: Vec<f32> = fitness
695 .evaluate_batch(&pop, &device)
696 .into_data()
697 .into_vec()
698 .expect("fitness host-read of a tensor this test just built");
699 assert!(
700 scores.iter().all(|s| s.is_finite()),
701 "all fitness values must be finite, got {scores:?}"
702 );
703 }
704
705 #[test]
707 #[allow(clippy::cast_precision_loss)]
708 fn converges_on_sum_of_squares() {
709 let coords: Vec<f32> = (0..5).map(|i| -2.0 + 4.0 * (i as f32) / 4.0).collect();
710 let mut inputs = Vec::new();
711 let mut targets = Vec::new();
712 for &x in &coords {
713 for &y in &coords {
714 inputs.push(vec![x, y]);
715 targets.push(x * x + y * y);
716 }
717 }
718 let best = run_gep(2, inputs, targets, 5, 500);
719 assert!(best <= 0.01, "expected MSE <= 0.01, got {best}");
720 }
721}