Skip to main content

rlevo_evolution/algorithms/gep/
strategy.rs

1//! The [`GepStrategy`] evolutionary engine and a symbolic-regression fitness.
2
3use 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/// Generation state for [`GepStrategy`].
23///
24/// Fields are private so the host-side fitness cache cannot fall out of sync
25/// with the population from outside this module; build one with
26/// [`try_new`](GepState::try_new) and read it through the accessors.
27#[derive(Debug, Clone)]
28pub struct GepState<B: Backend> {
29    /// Current population, shape `(pop_size, genome_len)`, `i32` symbol ids.
30    population: Tensor<B, 2, Int>,
31    /// Host-side fitness cache for the current population, in canonical
32    /// (maximise) space — *higher is better*. Empty until the first
33    /// [`Strategy::tell`].
34    fitnesses: Vec<f32>,
35    /// Best-so-far genome, shape `(1, genome_len)`.
36    best_genome: Option<Tensor<B, 2, Int>>,
37    /// Best-so-far fitness.
38    best_fitness: f32,
39    /// Generation counter.
40    generation: usize,
41}
42
43impl<B: Backend> GepState<B> {
44    /// Assembles a GEP state, checking the fitness cache matches the
45    /// population.
46    ///
47    /// # Errors
48    ///
49    /// Returns a [`ConfigError`] if `population` has zero rows or if
50    /// `fitnesses` is non-empty with a length other than `pop_size`. The
51    /// bootstrap state (empty `fitnesses`) is accepted.
52    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    /// Current population, shape `(pop_size, genome_len)`.
80    #[must_use]
81    pub fn population(&self) -> &Tensor<B, 2, Int> {
82        &self.population
83    }
84
85    /// Host-side fitness cache (empty at bootstrap, else `pop_size` long).
86    #[must_use]
87    pub fn fitnesses(&self) -> &[f32] {
88        &self.fitnesses
89    }
90
91    /// Best-so-far genome (shape `(1, genome_len)`), or `None` before the
92    /// first `tell`.
93    #[must_use]
94    pub fn best_genome(&self) -> Option<&Tensor<B, 2, Int>> {
95        self.best_genome.as_ref()
96    }
97
98    /// Best-so-far (canonical, maximise) fitness.
99    #[must_use]
100    pub fn best_fitness(&self) -> f32 {
101        self.best_fitness
102    }
103
104    /// Generation counter.
105    #[must_use]
106    pub fn generation(&self) -> usize {
107        self.generation
108    }
109}
110
111/// Gene Expression Programming as a generational [`Strategy`].
112///
113/// The genome is a `Tensor<B, 2, Int>` of shape `(pop_size, head_len +
114/// tail_len)`. Selection is roulette-wheel (fitness-proportionate) with
115/// elitism, following Ferreira (2001): the best-so-far individual is copied
116/// unchanged into every new generation, while the rest are produced by
117/// roulette selection followed by crossover, transposition, and locus-class
118/// point mutation. All randomness is host-side via
119/// [`seed_stream`]; each operator draws from its own
120/// [`SeedPurpose`] stream.
121///
122/// Decoding and phenotype evaluation are **not** part of the strategy — they
123/// live in the [`BatchFitnessFn`] (see [`GepSymRegression`]), keeping the
124/// ask/tell loop free of program interpretation.
125#[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    /// Builds a strategy over the given symbol alphabet.
133    #[must_use]
134    pub fn new(alphabet: Alphabet<F>) -> Self {
135        Self {
136            alphabet,
137            _backend: PhantomData,
138        }
139    }
140
141    /// The alphabet this strategy samples and decodes against.
142    #[must_use]
143    pub fn alphabet(&self) -> &Alphabet<F> {
144        &self.alphabet
145    }
146
147    /// Samples one fresh valid chromosome: head loci any symbol, tail loci
148    /// terminals only.
149    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
161/// Pulls an integer population tensor to host as per-row symbol chromosomes.
162fn 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        // Panics if the population tensor cannot be read as `i32` on the host.
168        .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
174/// Uploads per-row symbol chromosomes as an integer population tensor.
175fn 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
188/// Roulette-wheel selection of `k` parent indices from canonical (maximise)
189/// fitness.
190///
191/// Fitness is canonical — *higher is better*. Each individual's selection
192/// weight is its fitness shifted so the worst finite individual sits at a
193/// small floor `ε`, i.e. `weight = (f − min_finite) + ε`, so a higher
194/// fitness yields a larger weight. This is sense-agnostic: the harness has
195/// already mapped a cost objective into canonical space, so no `mse`
196/// appears here. Non-finite fitness contributes zero weight. If the total
197/// weight is non-positive (e.g. every individual diverged), selection falls
198/// back to uniform sampling.
199fn 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    /// Samples the initial population: each chromosome's head loci hold any
272    /// symbol and its tail loci hold terminals, so every individual is valid by
273    /// construction.
274    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    /// Returns the population to evaluate this generation.
300    ///
301    /// On the first call (no fitness cached yet) the initial population is
302    /// returned unchanged. On subsequent calls a new generation is bred from
303    /// the current one: roulette selection, then one-/two-point crossover,
304    /// IS/RIS transposition, and locus-class point mutation, with the
305    /// best-so-far individual copied into row 0 (elitism). Each operator draws
306    /// from its own [`SeedPurpose`] stream off a single base seed.
307    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        // First call: evaluate the initial population as-is.
315        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        // One base seed; four independent operator streams off it.
325        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        // Roulette selection -> offspring seeds.
333        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        // Crossover over consecutive pairs.
338        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        // Transposition + point mutation, per individual.
352        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        // Elitism: copy the best-so-far genome into row 0 unchanged.
369        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    /// Caches the evaluated population and its fitness, then updates the
381    /// best-so-far record. The returned population becomes the current
382    /// generation that the next [`ask`](Strategy::ask) breeds from.
383    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        // Cache this generation's fitness for the next `ask`'s roulette draw.
404        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/// A symbolic-regression [`BatchFitnessFn`] for GEP populations.
417///
418/// Holds the target dataset (input rows and expected outputs) and the alphabet
419/// to decode against. [`evaluate_batch`](BatchFitnessFn::evaluate_batch) pulls
420/// the population to host and, in parallel across rows (`rayon`), decodes each
421/// chromosome to an [`ExpressionTree`](super::ExpressionTree) and scores it by
422/// mean squared error. Decoding is deterministic, so the row-parallel order
423/// does not affect results.
424#[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    /// Builds the fitness function from an alphabet, the genome length, the
434    /// input rows, and the matching expected outputs.
435    ///
436    /// # Panics
437    ///
438    /// Panics if:
439    /// - `inputs` is empty — an empty dataset would make MSE `0.0 / 0.0 = NaN`
440    ///   fitness (`n_points == 0`), silently poisoning the population.
441    /// - `inputs` and `targets` differ in length.
442    /// - any input row does not have exactly `alphabet.n_vars` entries — a short
443    ///   row is otherwise silently zero-padded by
444    ///   [`ExpressionTree::eval`](super::ExpressionTree::eval)
445    ///   (`inputs.get(i).unwrap_or(0.0)`), so the regression would quietly fit
446    ///   the wrong target.
447    #[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        // Mean squared error is a cost — lower is better.
507        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    /// All-equal fitness gives every individual the same weight: draws are valid
521    /// indices and every index is reachable.
522    #[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    /// All-`NaN` fitness zeroes every weight, so selection falls back to
532    /// *uniform* sampling — not a degenerate always-index-0 draw. Over a large
533    /// deterministic sample each of the 4 candidates must appear roughly N/4
534    /// times (loose band), which a silent "always pick 0" fallback would fail.
535    #[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        // Uniform expectation is N/4 = 1000; allow a generous ±40% band so the
549        // fixed-seed draw cannot flake while still rejecting a collapsed
550        // distribution (e.g. every draw landing on one index).
551        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    /// A single-individual population can only ever pick index 0.
563    #[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    /// Requesting zero parents from an empty fitness slice yields no picks (and
571    /// never samples an empty range).
572    #[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    /// With all-negative (canonical) fitness the shift `f − min_finite` still
580    /// favours the least-negative individual; it is picked more often than the
581    /// most-negative one, and every draw is in range.
582    #[test]
583    fn roulette_negative_fitness_favours_highest() {
584        let mut rng = seed_stream(35, 0, SeedPurpose::Selection);
585        // Index 2 (−1.0) is the largest; index 0 (−100.0) the smallest.
586        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    /// Runs GEP on a dataset and returns the best MSE found.
611    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    /// Converges on `f(x) = x² + x + 1` over 20 points in [-1, 1].
637    #[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    /// Converges on `f(x) = sin(x) · x` over 20 points in [-3, 3].
648    #[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    /// An empty dataset is rejected at construction (would give NaN fitness).
659    #[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    /// A row whose width differs from `n_vars` is rejected (would be silently
666    /// zero-padded and fit the wrong target).
667    #[test]
668    #[should_panic(expected = "every input row must have exactly n_vars")]
669    fn test_gep_sym_regression_rejects_mismatched_row_width() {
670        // n_vars = 2 but the single row has only one entry.
671        let _ = GepSymRegression::new(alphabet(2), 15, vec![vec![1.0]], vec![0.0]);
672    }
673
674    /// A well-formed tiny dataset yields finite fitness for the zero genome.
675    #[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        // A valid chromosome: every locus is variable `x` (id 8), which decodes
687        // to the single-node tree `x`. An all-zeros genome would be all `add`
688        // with no terminal tail — a malformed chromosome that trips the separate
689        // decode out-of-bounds path (decode.rs §1.1), unrelated to this test.
690        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    /// Converges on `f(x, y) = x² + y²` over a 5×5 grid in [-2, 2]² (`n_vars` = 2).
706    #[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}