Skip to main content

gentle_ga/
lib.rs

1//! Gentle, ergonomic GA framework with strong typing and static dispatch.
2//!
3//! Design goals:
4//! - Minimal trait surface for users: implement `Problem` for your genome.
5//! - Sensible defaults: mutate/crossover/repair are optional.
6//! - Configurable selection policy (default tournament).
7//! - Clear separation of concerns: Problem = domain logic; Engine = orchestration.
8//! - Deterministic runs via seed; no RNG state stored in `Problem`.
9
10use rand::{rngs::StdRng, Rng, SeedableRng};
11use std::cmp::Ordering;
12
13/// Direction of optimization for fitness comparison.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum Optimize {
16    Maximize,
17    Minimize,
18}
19
20/// A genome with its evaluated fitness.
21#[derive(Clone, Debug)]
22pub struct Evaluated<G, F> {
23    pub genome: G,
24    pub fitness: F,
25}
26
27/// Core problem definition. Implement this to adapt the GA to your domain.
28///
29/// You must supply:
30/// - `random_genome` to create initial individuals
31/// - `fitness` to evaluate individuals
32///
33/// You may optionally override:
34/// - `mutate` (default: no-op)
35/// - `crossover` (default: clone `a`)
36/// - `repair` (default: no-op)
37///
38/// Methods accept an `rng` so all randomness flows from the engine, making
39/// runs deterministic with a given seed.
40pub trait Problem {
41    type Genome: Clone + Send + Sync + 'static;
42    type Fitness: PartialOrd + Copy + Send + Sync + 'static;
43
44    fn random_genome<R: Rng + ?Sized>(&mut self, rng: &mut R) -> Self::Genome;
45
46    fn fitness(&self, genome: &Self::Genome) -> Self::Fitness;
47
48    fn mutate<R: Rng + ?Sized>(&mut self, _genome: &mut Self::Genome, _rng: &mut R) {
49        // default no-op
50    }
51
52    fn crossover<R: Rng + ?Sized>(
53        &mut self,
54        a: &Self::Genome,
55        _b: &Self::Genome,
56        _rng: &mut R,
57    ) -> Self::Genome {
58        // default: clone a
59        a.clone()
60    }
61
62    fn repair(&mut self, _genome: &mut Self::Genome) {
63        // default no-op
64    }
65}
66
67/// Selection strategy for choosing parents from an evaluated population.
68///
69/// Implementors can be stateless or carry configuration (e.g., tournament size).
70pub trait Selection<P: Problem> {
71    fn select<'a, R: Rng + ?Sized>(
72        &self,
73        rng: &mut R,
74        population: &'a [Evaluated<P::Genome, P::Fitness>],
75        optimize: Optimize,
76    ) -> (&'a P::Genome, &'a P::Genome);
77}
78
79/// Default selection: tournament with configurable size `k` (>= 2).
80#[derive(Clone, Copy, Debug)]
81pub struct TournamentSelection {
82    pub k: usize,
83}
84
85impl Default for TournamentSelection {
86    fn default() -> Self {
87        Self { k: 3 }
88    }
89}
90
91impl<P: Problem> Selection<P> for TournamentSelection {
92    fn select<'a, R: Rng + ?Sized>(
93        &self,
94        rng: &mut R,
95        population: &'a [Evaluated<P::Genome, P::Fitness>],
96        optimize: Optimize,
97    ) -> (&'a P::Genome, &'a P::Genome) {
98        debug_assert!(population.len() >= 2, "population must have at least 2 individuals");
99        let pick_one = |rng: &mut R| {
100            // Sample `k` competitors uniformly with replacement, pick the best by fitness.
101            // For small populations, k will clamp to population size.
102            let k = self.k.max(1).min(population.len());
103            let mut best_idx = rng.gen_range(0..population.len());
104            for _ in 1..k {
105                let idx = rng.gen_range(0..population.len());
106                best_idx = better_index(population, best_idx, idx, optimize);
107            }
108            &population[best_idx].genome
109        };
110        (pick_one(rng), pick_one(rng))
111    }
112}
113
114/// Overall configuration for a GA run.
115#[derive(Clone, Debug)]
116pub struct Config {
117    pub pop_size: usize,
118    pub optimize: Optimize,
119    pub crossover_rate: f64, // probability to apply crossover (0..=1)
120    pub mutation_rate: f64,  // probability to apply mutation (0..=1)
121    pub elitism: usize,      // number of best individuals to carry over
122    pub seed: Option<u64>,   // deterministic seed; None => from entropy
123}
124
125impl Default for Config {
126    fn default() -> Self {
127        Self {
128            pop_size: 100,
129            optimize: Optimize::Maximize,
130            crossover_rate: 0.9,
131            mutation_rate: 0.1,
132            elitism: 1,
133            seed: None,
134        }
135    }
136}
137
138/// Builder for GA with ergonomic configuration and sensible defaults.
139pub struct Builder<P: Problem, S: Selection<P> = TournamentSelection> {
140    problem: P,
141    config: Config,
142    selection: S,
143}
144
145impl<P: Problem> Builder<P, TournamentSelection> {
146    pub fn new(problem: P) -> Self {
147        Self {
148            problem,
149            config: Config::default(),
150            selection: TournamentSelection::default(),
151        }
152    }
153}
154
155impl<P: Problem, S: Selection<P>> Builder<P, S> {
156    pub fn pop_size(mut self, n: usize) -> Self {
157        self.config.pop_size = n.max(2);
158        self
159    }
160    pub fn optimize(mut self, opt: Optimize) -> Self {
161        self.config.optimize = opt;
162        self
163    }
164    pub fn mutation_rate(mut self, p: f64) -> Self {
165        self.config.mutation_rate = clamp01(p);
166        self
167    }
168    pub fn crossover_rate(mut self, p: f64) -> Self {
169        self.config.crossover_rate = clamp01(p);
170        self
171    }
172    pub fn elitism(mut self, k: usize) -> Self {
173        self.config.elitism = k;
174        self
175    }
176    pub fn seed(mut self, seed: u64) -> Self {
177        self.config.seed = Some(seed);
178        self
179    }
180    pub fn selection<S2: Selection<P>>(self, selection: S2) -> Builder<P, S2> {
181        Builder {
182            problem: self.problem,
183            config: self.config,
184            selection,
185        }
186    }
187    pub fn build(mut self) -> GA<P, S> {
188        let mut rng = match self.config.seed {
189            Some(s) => StdRng::seed_from_u64(s),
190            None => StdRng::from_entropy(),
191        };
192
193        // Initialize and evaluate population
194        let mut pop = Vec::with_capacity(self.config.pop_size);
195        for _ in 0..self.config.pop_size {
196            let g = self.problem.random_genome(&mut rng);
197            let f = self.problem.fitness(&g);
198            pop.push(Evaluated { genome: g, fitness: f });
199        }
200        GA {
201            problem: self.problem,
202            config: self.config,
203            selection: self.selection,
204            rng,
205            population: pop,
206            generation: 0,
207        }
208    }
209}
210
211/// Genetic Algorithm engine.
212pub struct GA<P: Problem, S: Selection<P> = TournamentSelection> {
213    problem: P,
214    config: Config,
215    selection: S,
216    rng: StdRng,
217    population: Vec<Evaluated<P::Genome, P::Fitness>>,
218    generation: usize,
219}
220
221/// Summary of a completed run.
222#[derive(Clone, Debug)]
223pub struct RunResult<G, F> {
224    pub generations: usize,
225    pub best: Evaluated<G, F>,
226    pub average_fitness: f64,
227}
228
229impl<P: Problem, S: Selection<P>> GA<P, S> {
230    pub fn builder(problem: P) -> Builder<P, TournamentSelection> {
231        Builder::new(problem)
232    }
233
234    /// Access the current generation count.
235    pub fn generation(&self) -> usize {
236        self.generation
237    }
238
239    /// Returns a reference to the best individual in the current population.
240    pub fn best(&self) -> &Evaluated<P::Genome, P::Fitness> {
241        best_of(&self.population, self.config.optimize)
242            .expect("population must be non-empty")
243    }
244
245    /// Returns the mean fitness of the current population as f64.
246    pub fn average_fitness(&self) -> f64
247    where
248        P::Fitness: Into<f64>,
249    {
250        let sum: f64 = self.population.iter().map(|e| e.fitness.into()).sum();
251        sum / self.population.len() as f64
252    }
253
254    /// Advances the population by one generation.
255    pub fn step(&mut self) {
256        let n = self.population.len();
257        let elitism = self.config.elitism.min(n);
258
259        // 1) Elites
260        let mut next: Vec<Evaluated<P::Genome, P::Fitness>> = if elitism > 0 {
261            let mut sorted = self.population.clone();
262            sorted.sort_by(|a, b| cmp_fit(self.config.optimize, &a.fitness, &b.fitness));
263            sorted.into_iter().rev().take(elitism).collect()
264        } else {
265            Vec::with_capacity(n)
266        };
267
268        // 2) Offspring until next population full
269        while next.len() < n {
270            let (pa, pb) = self.selection.select(&mut self.rng, &self.population, self.config.optimize);
271
272            // crossover
273            let mut child = if chance(&mut self.rng, self.config.crossover_rate) {
274                self.problem.crossover(pa, pb, &mut self.rng)
275            } else {
276                pa.clone()
277            };
278
279            // mutate
280            if chance(&mut self.rng, self.config.mutation_rate) {
281                self.problem.mutate(&mut child, &mut self.rng);
282            }
283
284            // repair and evaluate
285            self.problem.repair(&mut child);
286            let fit = self.problem.fitness(&child);
287            next.push(Evaluated { genome: child, fitness: fit });
288        }
289
290        self.population = next;
291        self.generation += 1;
292    }
293
294    /// Runs for a fixed number of generations and returns a summary.
295    pub fn run_for(&mut self, generations: usize) -> RunResult<P::Genome, P::Fitness>
296    where
297        P::Fitness: Into<f64>,
298    {
299        for _ in 0..generations {
300            self.step();
301        }
302        RunResult {
303            generations: self.generation,
304            best: self.best().clone(),
305            average_fitness: self.average_fitness(),
306        }
307    }
308
309    /// Runs until the predicate returns true; evaluated at the start of each generation.
310    /// Returns when the predicate returns true or `max_generations` is reached.
311    pub fn run_until<F>(
312        &mut self,
313        max_generations: usize,
314        mut stop_when: F,
315    ) -> RunResult<P::Genome, P::Fitness>
316    where
317        F: FnMut(usize, &Evaluated<P::Genome, P::Fitness>) -> bool,
318        P::Fitness: Into<f64>,
319    {
320        for _ in 0..max_generations {
321            if stop_when(self.generation, self.best()) {
322                break;
323            }
324            self.step();
325        }
326        RunResult {
327            generations: self.generation,
328            best: self.best().clone(),
329            average_fitness: self.average_fitness(),
330        }
331    }
332
333    /// Immutable view of the population.
334    pub fn population(&self) -> &[Evaluated<P::Genome, P::Fitness>] {
335        &self.population
336    }
337
338    /// Mutable access to the underlying problem (e.g., to tweak parameters mid-run).
339    pub fn problem_mut(&mut self) -> &mut P {
340        &mut self.problem
341    }
342}
343
344// ---------- Internals / helpers ----------
345
346fn clamp01(p: f64) -> f64 {
347    if p.is_nan() {
348        0.0
349    } else if p < 0.0 {
350        0.0
351    } else if p > 1.0 {
352        1.0
353    } else {
354        p
355    }
356}
357
358fn chance<R: Rng + ?Sized>(rng: &mut R, p: f64) -> bool {
359    // Use Bernoulli sampling; also clamp to be robust against accidental out-of-range inputs.
360    rng.gen_bool(clamp01(p))
361}
362
363fn cmp_fit<F: PartialOrd>(optimize: Optimize, a: &F, b: &F) -> Ordering {
364    // Handle NaN as Equal to avoid panics; users should ensure Fitness is well-defined.
365    let ord = a.partial_cmp(b).unwrap_or(Ordering::Equal);
366    match optimize {
367        Optimize::Maximize => ord,
368        Optimize::Minimize => ord.reverse(),
369    }
370}
371
372fn best_of<G: Clone, F: PartialOrd>(
373    pop: &[Evaluated<G, F>],
374    optimize: Optimize,
375) -> Option<&Evaluated<G, F>> {
376    pop.iter()
377        .max_by(|a, b| cmp_fit(optimize, &a.fitness, &b.fitness))
378}
379
380fn better_index<G, F: PartialOrd>(
381    pop: &[Evaluated<G, F>],
382    i: usize,
383    j: usize,
384    optimize: Optimize,
385) -> usize {
386    match cmp_fit(optimize, &pop[i].fitness, &pop[j].fitness) {
387        Ordering::Less => j,
388        _ => i,
389    }
390}
391
392/// Prelude for convenient imports.
393pub mod prelude {
394    pub use super::{
395        Builder, Config, Evaluated, GA, Optimize, Problem, Selection, TournamentSelection,
396    };
397}
398