1use 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#[derive(Debug, Clone, Copy)]
46pub enum GaSelection {
47 Tournament { size: usize },
49}
50
51#[derive(Debug, Clone, Copy)]
53pub enum GaCrossover {
54 BlxAlpha { alpha: NonNegativeRate },
57 Uniform { p: Probability },
60}
61
62#[derive(Debug, Clone, Copy)]
64pub enum GaReplacement {
65 Generational,
67 Elitist { elitism_k: usize },
69}
70
71#[derive(Debug, Clone)]
73pub struct GaConfig {
74 pub pop_size: usize,
76 pub genome_dim: usize,
78 pub bounds: Bounds,
80 pub mutation_sigma: NonNegativeRate,
83 pub selection: GaSelection,
85 pub crossover: GaCrossover,
87 pub replacement: GaReplacement,
89}
90
91impl GaConfig {
92 #[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 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#[derive(Debug, Clone)]
148pub struct GaState<B: Backend> {
149 pub population: Tensor<B, 2>,
151 pub fitness: Vec<f32>,
154 pub best_genome: Option<Tensor<B, 2>>,
156 pub best_fitness: f32,
158 pub generation: usize,
160}
161
162#[derive(Debug, Clone, Copy, Default)]
187pub struct GeneticAlgorithm<B: Backend> {
188 _backend: PhantomData<fn() -> B>,
189}
190
191impl<B: Backend> GeneticAlgorithm<B> {
192 #[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 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 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 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 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 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 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 let offspring = gaussian_mutation(offspring, *mutation_sigma, &mut mutation_rng, device);
344
345 let (lo, hi): (f32, f32) = params.bounds.into();
347 let offspring = offspring.clamp(lo, hi);
348
349 (offspring, state.clone())
350 }
351
352 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 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 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 #[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 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}