Skip to main content

rlevo_evolution/algorithms/
ga.rs

1//! Real-valued Genetic Algorithm.
2//!
3//! A canonical textbook GA over `Tensor<B, 2>` populations:
4//!
5//! 1. Evaluate the current population (done externally by the harness).
6//! 2. Select parents via [`crate::ops::selection::tournament_select`].
7//! 3. Recombine via [`crate::ops::crossover::blx_alpha`] or
8//!    [`crate::ops::crossover::uniform_crossover`].
9//! 4. Mutate via [`crate::ops::mutation::gaussian_mutation`].
10//! 5. Replace via [`crate::ops::replacement::elitist`] or
11//!    [`crate::ops::replacement::generational`].
12//!
13//! Operator variants are enum-selected via the [`GaConfig`] to avoid a
14//! generic explosion; custom operator mixtures can still be built
15//! bottom-up against the [`Strategy`] trait directly.
16//!
17//! # References
18//!
19//! - Goldberg (1989), *Genetic Algorithms in Search, Optimization, and
20//!   Machine Learning*.
21//! - Deb & Agrawal (1995), *Simulated binary crossover for continuous
22//!   search space*.
23
24use std::marker::PhantomData;
25
26use burn::tensor::{Tensor, TensorData, backend::Backend};
27use rand::Rng;
28use rand::RngExt;
29
30use crate::ops::{
31    crossover::{blx_alpha, uniform_crossover},
32    mutation::gaussian_mutation,
33    replacement::{elitist, generational},
34    selection::tournament_select,
35};
36use rlevo_core::bounds::Bounds;
37use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
38use rlevo_core::probability::Probability;
39use rlevo_core::rate::NonNegativeRate;
40
41use crate::rng::{SeedPurpose, seed_stream};
42use crate::strategy::{Strategy, StrategyMetrics};
43
44/// Selection algorithm choice.
45#[derive(Debug, Clone, Copy)]
46pub enum GaSelection {
47    /// k-tournament selection.
48    Tournament { size: usize },
49}
50
51/// Crossover algorithm choice.
52#[derive(Debug, Clone, Copy)]
53pub enum GaCrossover {
54    /// BLX-α real-valued crossover. `alpha` is a non-negative expansion
55    /// factor (finite, `>= 0`), valid by construction.
56    BlxAlpha { alpha: NonNegativeRate },
57    /// Uniform swap crossover with per-gene probability `p` (valid by
58    /// construction, `[0, 1]`).
59    Uniform { p: Probability },
60}
61
62/// Replacement algorithm choice.
63#[derive(Debug, Clone, Copy)]
64pub enum GaReplacement {
65    /// Offspring replace the entire parent population.
66    Generational,
67    /// `elitism_k` best parents persist; the rest come from offspring.
68    Elitist { elitism_k: usize },
69}
70
71/// Static configuration for a [`GeneticAlgorithm`] run.
72#[derive(Debug, Clone)]
73pub struct GaConfig {
74    /// Number of individuals per generation.
75    pub pop_size: usize,
76    /// Genome dimensionality.
77    pub genome_dim: usize,
78    /// Lower / upper bound on initial samples and clamping.
79    pub bounds: Bounds,
80    /// σ for isotropic Gaussian mutation. A non-negative step size (finite,
81    /// `>= 0`), valid by construction.
82    pub mutation_sigma: NonNegativeRate,
83    /// Selection operator.
84    pub selection: GaSelection,
85    /// Crossover operator.
86    pub crossover: GaCrossover,
87    /// Replacement policy.
88    pub replacement: GaReplacement,
89}
90
91impl GaConfig {
92    /// Sensible defaults for small-scale continuous optimization.
93    #[must_use]
94    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
95        Self {
96            pop_size,
97            genome_dim,
98            bounds: Bounds::new(-5.12, 5.12),
99            mutation_sigma: NonNegativeRate::new(0.3),
100            selection: GaSelection::Tournament { size: 2 },
101            crossover: GaCrossover::BlxAlpha {
102                alpha: NonNegativeRate::new(0.5),
103            },
104            replacement: GaReplacement::Elitist { elitism_k: 1 },
105        }
106    }
107}
108
109impl Validate for GaConfig {
110    fn validate(&self) -> Result<(), ConfigError> {
111        const C: &str = "GaConfig";
112        config::at_least(C, "pop_size", self.pop_size, 1)?;
113        config::nonzero(C, "genome_dim", self.genome_dim)?;
114        // `mutation_sigma` is a `NonNegativeRate` (finite, `>= 0`); the
115        // `GaCrossover` payloads (`alpha: NonNegativeRate`, `p: Probability`)
116        // are likewise valid by construction — no scalar `in_range` checks
117        // here, see ADR 0031.
118        match self.selection {
119            GaSelection::Tournament { size } => {
120                config::at_least(C, "selection.size", size, 1)?;
121                if size > self.pop_size {
122                    return Err(ConfigError {
123                        config: C,
124                        field: "selection.size",
125                        kind: ConstraintKind::Custom("tournament size must not exceed pop_size"),
126                    });
127                }
128            }
129        }
130        match self.replacement {
131            GaReplacement::Generational => {}
132            GaReplacement::Elitist { elitism_k } => {
133                if elitism_k > self.pop_size {
134                    return Err(ConfigError {
135                        config: C,
136                        field: "replacement.elitism_k",
137                        kind: ConstraintKind::Custom("elitism_k must not exceed pop_size"),
138                    });
139                }
140            }
141        }
142        Ok(())
143    }
144}
145
146/// Generation-to-generation state carried by [`GeneticAlgorithm`].
147#[derive(Debug, Clone)]
148pub struct GaState<B: Backend> {
149    /// Current population, shape `(pop_size, genome_dim)`.
150    pub population: Tensor<B, 2>,
151    /// Cached fitness for the current population. Empty on init until
152    /// the first `tell` call overwrites it.
153    pub fitness: Vec<f32>,
154    /// Best-so-far genome, shape `(1, genome_dim)`.
155    pub best_genome: Option<Tensor<B, 2>>,
156    /// Best-so-far fitness.
157    pub best_fitness: f32,
158    /// Completed-generation counter.
159    pub generation: usize,
160}
161
162/// Real-valued canonical Genetic Algorithm.
163///
164/// # Example
165///
166/// ```no_run
167/// use burn::backend::Flex;
168/// use rlevo_core::bounds::Bounds;
169/// use rlevo_core::rate::NonNegativeRate;
170/// use rlevo_evolution::algorithms::ga::{
171///     GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
172/// };
173///
174/// let strategy = GeneticAlgorithm::<Flex>::new();
175/// let params = GaConfig {
176///     pop_size: 64,
177///     genome_dim: 10,
178///     bounds: Bounds::new(-5.12, 5.12),
179///     mutation_sigma: NonNegativeRate::new(0.3),
180///     selection: GaSelection::Tournament { size: 2 },
181///     crossover: GaCrossover::BlxAlpha { alpha: NonNegativeRate::new(0.5) },
182///     replacement: GaReplacement::Elitist { elitism_k: 2 },
183/// };
184/// let _ = (strategy, params);
185/// ```
186#[derive(Debug, Clone, Copy, Default)]
187pub struct GeneticAlgorithm<B: Backend> {
188    _backend: PhantomData<fn() -> B>,
189}
190
191impl<B: Backend> GeneticAlgorithm<B> {
192    /// Build a new (stateless) strategy object.
193    #[must_use]
194    pub fn new() -> Self {
195        Self {
196            _backend: PhantomData,
197        }
198    }
199
200    fn sample_initial_population(
201        params: &GaConfig,
202        rng: &mut dyn Rng,
203        device: &<B as burn::tensor::backend::BackendTypes>::Device,
204    ) -> Tensor<B, 2> {
205        let (lo, hi): (f32, f32) = params.bounds.into();
206        // Host-sample the initial population from a deterministic
207        // `seed_stream` rather than the process-wide Flex RNG (`B::seed` +
208        // `Tensor::random`), whose draws interleave with sibling tests under
209        // the parallel runner and are not reproducible across schedules.
210        let pop = params.pop_size;
211        let genome_dim = params.genome_dim;
212        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
213        let mut rows = Vec::with_capacity(pop * genome_dim);
214        for _ in 0..pop * genome_dim {
215            rows.push(lo + (hi - lo) * stream.random::<f32>());
216        }
217        Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
218    }
219}
220
221impl<B: Backend> Strategy<B> for GeneticAlgorithm<B>
222where
223    B::Device: Clone,
224{
225    type Params = GaConfig;
226    type State = GaState<B>;
227    type Genome = Tensor<B, 2>;
228
229    /// Build the initial state.
230    ///
231    /// Samples an `(pop_size, genome_dim)` real-valued population uniformly
232    /// within `params.bounds` using a host RNG derived from `rng`. Sets
233    /// `fitness` to empty and `best_fitness` to `f32::NEG_INFINITY` (the
234    /// worst value under the maximise convention); the first
235    /// [`tell`](Self::tell) call populates both.
236    fn init(
237        &self,
238        params: &GaConfig,
239        rng: &mut dyn Rng,
240        device: &<B as burn::tensor::backend::BackendTypes>::Device,
241    ) -> GaState<B> {
242        debug_assert!(
243            params.validate().is_ok(),
244            "invalid GaConfig reached init: {params:?}"
245        );
246        let population = Self::sample_initial_population(params, rng, device);
247        GaState {
248            population,
249            fitness: Vec::new(),
250            best_genome: None,
251            best_fitness: f32::NEG_INFINITY,
252            generation: 0,
253        }
254    }
255
256    /// Propose the next offspring population.
257    ///
258    /// On the very first call (before any [`tell`](Self::tell)), `state.fitness`
259    /// is empty — the harness has not evaluated the seed population yet. In
260    /// that case the unchanged seed population is returned so the harness can
261    /// evaluate and pass it back to `tell`.
262    ///
263    /// On subsequent calls the method runs selection → crossover → mutation,
264    /// deriving three independent host sub-streams from `rng` via
265    /// [`crate::rng::seed_stream`]:
266    ///
267    /// - `SeedPurpose::Selection` — two independent tournament draws
268    ///   (parents A and parents B);
269    /// - `SeedPurpose::Crossover` — BLX-α or uniform crossover;
270    /// - `SeedPurpose::Mutation` — isotropic Gaussian perturbation.
271    ///
272    /// After mutation, offspring are clamped to `params.bounds`.
273    fn ask(
274        &self,
275        params: &GaConfig,
276        state: &GaState<B>,
277        rng: &mut dyn Rng,
278        device: &<B as burn::tensor::backend::BackendTypes>::Device,
279    ) -> (Tensor<B, 2>, GaState<B>) {
280        // On the first call, state.fitness is empty; the harness has not
281        // evaluated anyone yet. Return the initial population unchanged.
282        if state.fitness.is_empty() {
283            return (state.population.clone(), state.clone());
284        }
285
286        let GaConfig {
287            pop_size,
288            mutation_sigma,
289            selection,
290            crossover,
291            ..
292        } = params;
293
294        let mut crossover_rng = seed_stream(
295            rng.next_u64(),
296            state.generation as u64,
297            SeedPurpose::Crossover,
298        );
299        let mut mutation_rng = seed_stream(
300            rng.next_u64(),
301            state.generation as u64,
302            SeedPurpose::Mutation,
303        );
304        let mut selection_rng = seed_stream(
305            rng.next_u64(),
306            state.generation as u64,
307            SeedPurpose::Selection,
308        );
309
310        // 1. Select two tournaments' worth of parents.
311        let parents_a = match selection {
312            GaSelection::Tournament { size } => tournament_select(
313                &state.population,
314                &state.fitness,
315                *size,
316                *pop_size,
317                &mut selection_rng,
318                device,
319            ),
320        };
321        let parents_b = match selection {
322            GaSelection::Tournament { size } => tournament_select(
323                &state.population,
324                &state.fitness,
325                *size,
326                *pop_size,
327                &mut selection_rng,
328                device,
329            ),
330        };
331
332        // 2. Recombine. Crossover draws from the host `crossover_rng`.
333        let offspring = match crossover {
334            GaCrossover::BlxAlpha { alpha } => {
335                blx_alpha(parents_a, parents_b, *alpha, &mut crossover_rng, device)
336            }
337            GaCrossover::Uniform { p } => {
338                uniform_crossover(parents_a, parents_b, *p, &mut crossover_rng, device)
339            }
340        };
341
342        // 3. Mutate from the host `mutation_rng`.
343        let offspring = gaussian_mutation(offspring, *mutation_sigma, &mut mutation_rng, device);
344
345        // 4. Clamp to bounds.
346        let (lo, hi): (f32, f32) = params.bounds.into();
347        let offspring = offspring.clamp(lo, hi);
348
349        (offspring, state.clone())
350    }
351
352    /// Consume offspring fitness and produce the next generation's state.
353    ///
354    /// The first call (when `state.fitness` is empty) caches the seed
355    /// population's fitness and increments the generation counter; no
356    /// replacement is performed.
357    ///
358    /// On subsequent calls the replacement policy is applied:
359    ///
360    /// - [`GaReplacement::Generational`] — offspring completely replace
361    ///   parents.
362    /// - [`GaReplacement::Elitist`] — the `elitism_k` highest-fitness parents
363    ///   survive; the remainder come from offspring.
364    ///
365    /// `fitness` must have shape `(pop_size,)` in the canonical maximise
366    /// convention — higher is better.
367    fn tell(
368        &self,
369        params: &GaConfig,
370        population: Tensor<B, 2>,
371        fitness: Tensor<B, 1>,
372        mut state: GaState<B>,
373        _rng: &mut dyn Rng,
374    ) -> (GaState<B>, StrategyMetrics) {
375        let fitness_host = fitness
376            .into_data()
377            .into_vec::<f32>()
378            .expect("fitness tensor must be readable as f32");
379
380        // First `tell` after `init`: cache fitness for the seed population.
381        if state.fitness.is_empty() {
382            state.fitness.clone_from(&fitness_host);
383            state.generation += 1;
384            update_best(&mut state, &population, &fitness_host);
385            let m = StrategyMetrics::from_host_fitness(
386                state.generation,
387                &fitness_host,
388                state.best_fitness,
389            );
390            state.best_fitness = m.best_fitness_ever();
391            return (state, m);
392        }
393
394        let device = state.population.device();
395        let (next_pop, next_fitness) = match params.replacement {
396            GaReplacement::Generational => generational::<B>(
397                state.population.clone(),
398                &state.fitness,
399                population.clone(),
400                fitness_host.clone(),
401            ),
402            GaReplacement::Elitist { elitism_k } => elitist::<B>(
403                state.population.clone(),
404                &state.fitness,
405                population.clone(),
406                &fitness_host,
407                elitism_k,
408                &device,
409            ),
410        };
411
412        update_best(&mut state, &next_pop, &next_fitness);
413        state.population = next_pop;
414        state.fitness.clone_from(&next_fitness);
415        state.generation += 1;
416        let m =
417            StrategyMetrics::from_host_fitness(state.generation, &next_fitness, state.best_fitness);
418        state.best_fitness = m.best_fitness_ever();
419        (state, m)
420    }
421
422    /// Return the best-so-far genome and its fitness.
423    ///
424    /// Returns `None` before the first [`tell`](Self::tell) call.
425    /// The fitness value uses the canonical maximise convention (higher is better).
426    fn best(&self, state: &GaState<B>) -> Option<(Tensor<B, 2>, f32)> {
427        state
428            .best_genome
429            .as_ref()
430            .map(|g| (g.clone(), state.best_fitness))
431    }
432}
433
434fn update_best<B: Backend>(state: &mut GaState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
435    if fitness.is_empty() {
436        return;
437    }
438    let mut best_idx = 0_usize;
439    let mut best_f = fitness[0];
440    for (i, &f) in fitness.iter().enumerate().skip(1) {
441        if f > best_f {
442            best_f = f;
443            best_idx = i;
444        }
445    }
446    if best_f > state.best_fitness {
447        let device = pop.device();
448        #[allow(clippy::cast_possible_wrap)]
449        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
450            TensorData::new(vec![best_idx as i64], [1]),
451            &device,
452        );
453        state.best_genome = Some(pop.clone().select(0, idx));
454        state.best_fitness = best_f;
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::fitness::FromFitnessEvaluable;
462    use crate::strategy::EvolutionaryHarness;
463    use burn::backend::Flex;
464    use rlevo_core::fitness::FitnessEvaluable;
465
466    type TestBackend = Flex;
467
468    #[test]
469    fn default_config_validates() {
470        assert!(GaConfig::default_for(64, 10).validate().is_ok());
471    }
472
473    #[test]
474    fn rejects_tournament_larger_than_pop() {
475        let mut cfg = GaConfig::default_for(8, 10);
476        cfg.selection = GaSelection::Tournament { size: 16 };
477        assert_eq!(cfg.validate().unwrap_err().field, "selection.size");
478    }
479
480    struct Sphere;
481    struct SphereFit;
482    impl FitnessEvaluable for SphereFit {
483        type Individual = Vec<f64>;
484        type Landscape = Sphere;
485        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
486            x.iter().map(|v| v * v).sum()
487        }
488    }
489
490    #[test]
491    fn ga_converges_on_sphere_d2() {
492        let device = Default::default();
493        let strategy = GeneticAlgorithm::<TestBackend>::new();
494        let params = GaConfig {
495            pop_size: 64,
496            genome_dim: 2,
497            bounds: Bounds::new(-5.0, 5.0),
498            mutation_sigma: NonNegativeRate::new(0.2),
499            selection: GaSelection::Tournament { size: 2 },
500            crossover: GaCrossover::BlxAlpha {
501                alpha: NonNegativeRate::new(0.5),
502            },
503            replacement: GaReplacement::Elitist { elitism_k: 1 },
504        };
505        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
506
507        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
508            strategy, params, fitness_fn, 42, device, 200,
509        )
510        .expect("valid params");
511        harness.reset();
512        loop {
513            let step = harness.step(());
514            if step.done {
515                break;
516            }
517        }
518        let m = harness.latest_metrics().unwrap();
519        assert!(
520            m.best_fitness_ever() < 1e-2,
521            "expected Sphere-D2 convergence, got best_fitness_ever={}",
522            m.best_fitness_ever()
523        );
524    }
525
526    /// §7.2 monotone-`best_fitness_ever` property: the rolling best fitness a
527    /// caller reads from [`StrategyMetrics::best_fitness_ever`] must never
528    /// decrease across generations. Driven on a *maximise* objective (so the
529    /// user-space value is the canonical value unflipped), the elitist GA can
530    /// only ever raise or hold the rolling best. (Determinism is covered in
531    /// `tests/determinism.rs` — not duplicated here.)
532    #[test]
533    fn best_fitness_ever_is_monotone_nondecreasing() {
534        use rlevo_core::objective::ObjectiveSense;
535
536        let device = Default::default();
537        let strategy = GeneticAlgorithm::<TestBackend>::new();
538        let params = GaConfig {
539            pop_size: 32,
540            genome_dim: 3,
541            bounds: Bounds::new(-5.0, 5.0),
542            mutation_sigma: NonNegativeRate::new(0.3),
543            selection: GaSelection::Tournament { size: 2 },
544            crossover: GaCrossover::BlxAlpha {
545                alpha: NonNegativeRate::new(0.5),
546            },
547            replacement: GaReplacement::Elitist { elitism_k: 1 },
548        };
549        // Maximise sum-of-squares: the objective sense is irrelevant to the
550        // property (monotonicity, not convergence), but a maximise sense makes
551        // the reported user-space value equal the canonical rolling best.
552        let fitness_fn =
553            FromFitnessEvaluable::with_sense(SphereFit, Sphere, ObjectiveSense::Maximize);
554
555        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
556            strategy, params, fitness_fn, 123, device, 40,
557        )
558        .expect("valid params");
559        harness.reset();
560
561        let mut prev: f32 = f32::NEG_INFINITY;
562        loop {
563            let step = harness.step(());
564            let cur: f32 = harness.latest_metrics().unwrap().best_fitness_ever();
565            assert!(
566                cur >= prev,
567                "best_fitness_ever must be non-decreasing: {cur} >= {prev}"
568            );
569            prev = cur;
570            if step.done {
571                break;
572            }
573        }
574    }
575}