From bf112e460b58d10eb95a55292d67080dbeecd0bf Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:04:07 +0000 Subject: [PATCH 01/38] implement SpeiatedFitnessEliminator --- genetic-rs-common/src/builtin/eliminator.rs | 134 ++++++++++++++++++++ genetic-rs-common/src/lib.rs | 4 + genetic-rs-common/src/prelude.rs | 3 + genetic-rs-common/src/speciation.rs | 73 +++++++++++ 4 files changed, 214 insertions(+) create mode 100644 genetic-rs-common/src/speciation.rs diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index b220913..833aeff 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -523,3 +523,137 @@ mod knockout { #[cfg(feature = "knockout")] pub use knockout::*; + +#[cfg(feature = "speciation")] +mod speciation { + use crate::{prelude::*, speciation::SpeciatedPopulation}; + + //// An eliminator that attempts to preserve new experimental structures by dividing a genome's + /// fitness by the number of genomes in its species. + pub struct SpeciatedFitnessEliminator, G: Speciated + FeatureBoundedGenome, O: FeatureBoundedFitnessObserver = ()> { + /// The divergence threshold used to determine whether a genome belongs in a species. + /// See [`SpeciatedPopulation::threshold`] for more info. + pub speciation_threshold: f32, + + /// The fitness function used to evaluate genomes. + pub fitness_fn: F, + + /// The percentage of genomes to keep. Must be between 0.0 and 1.0. + pub keep_threshold: f32, + + /// The fitness observer used to observe fitness scores. + pub observer: Option, + + _marker: std::marker::PhantomData, + } + + impl SpeciatedFitnessEliminator + where + F: FeatureBoundedFitnessFn, + G: Speciated + FeatureBoundedGenome, + O: FeatureBoundedFitnessObserver, + { + /// Creates a new [`SpeciatedFitnessEliminator`] with a given fitness function and thresholds. + /// Panics if the thresholds are not between 0.0 and 1.0. + pub fn new(fitness_fn: F, speciation_threshold: f32, keep_threshold: f32, observer: Option) -> Self { + if !(0.0..=1.0).contains(&speciation_threshold) { + panic!("Speciation threshold must be between 0.0 and 1.0"); + } + if !(0.0..=1.0).contains(&keep_threshold) { + panic!("Keep threshold must be between 0.0 and 1.0"); + } + Self { + fitness_fn, + speciation_threshold, + keep_threshold, + observer, + _marker: std::marker::PhantomData, + } + } + + /// Creates a new [`SpeciatedFitnessEliminator`] from a regular [`FitnessEliminator`] and a speciation threshold. + /// Useful since you can use the builder for [`FitnessEliminator`] to construct the fitness function and observer, then convert it into a [`SpeciatedFitnessEliminator`] with this method. + pub fn from_fitness_eliminator(fitness_eliminator: FitnessEliminator, speciation_threshold: f32) -> Self { + Self::new(fitness_eliminator.fitness_fn, speciation_threshold, fitness_eliminator.threshold, Some(fitness_eliminator.observer)) + } + + /// Calculates the fitness of each genome, dividing by the number of genomes in its species, and sorts them by fitness. + /// Returns a vector of tuples containing the genome and its fitness score. + #[cfg(not(feature = "rayon"))] + pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { + let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold); + let mut fitnesses = vec![0.0; genomes.len()]; + + for species in population.species { + let len = species.len() as f32; + for index in species { + let genome = &genomes[index]; + let fitness = self.fitness_fn.fitness(genome) / len; + fitnesses[index] = fitness; + } + } + + let mut fitnesses: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses.into_iter()).collect(); + fitnesses.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); + fitnesses + } + + /// Calculates the fitness of each genome, dividing by the number of genomes in its species, and sorts them by fitness. + /// Returns a vector of tuples containing the genome and its fitness score. + #[cfg(feature = "rayon")] + pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { + let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold); + + // Create fitnesses array with default values + let mut fitnesses = vec![0.0; genomes.len()]; + + // Process species sequentially, but within each species compute fitnesses in parallel + for species in population.species { + let len = species.len() as f32; + let updates: Vec<(usize, f32)> = species + .par_iter() + .map(|&index| { + let genome = &genomes[index]; + let fitness = self.fitness_fn.fitness(genome) / len; + (index, fitness) + }) + .collect(); + + for (index, fitness) in updates { + fitnesses[index] = fitness; + } + } + + // Pair genomes with fitnesses and sort + let mut result: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses.into_iter()).collect(); + result.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); + result + } + } + + impl Eliminator for SpeciatedFitnessEliminator + where + F: FeatureBoundedFitnessFn, + G: Speciated + FeatureBoundedGenome, + O: FeatureBoundedFitnessObserver, + { + #[cfg(not(feature = "rayon"))] + fn eliminate(&mut self, genomes: Vec) -> Vec { + let mut fitnesses = self.calculate_and_sort(genomes); + let median_index = (fitnesses.len() as f32) * self.keep_threshold; + fitnesses.truncate(median_index as usize + 1); + fitnesses.into_iter().map(|(g, _)| g).collect() + } + + #[cfg(feature = "rayon")] + fn eliminate(&mut self, genomes: Vec) -> Vec { + let mut fitnesses = self.calculate_and_sort(genomes); + let median_index = (fitnesses.len() as f32) * self.keep_threshold; + fitnesses.truncate(median_index as usize + 1); + fitnesses.into_par_iter().map(|(g, _)| g).collect() + } + } +} + +#[cfg(feature = "speciation")] +pub use speciation::*; \ No newline at end of file diff --git a/genetic-rs-common/src/lib.rs b/genetic-rs-common/src/lib.rs index 05b0f2c..f77903d 100644 --- a/genetic-rs-common/src/lib.rs +++ b/genetic-rs-common/src/lib.rs @@ -8,6 +8,10 @@ #[cfg(feature = "builtin")] pub mod builtin; +/// Common speciation code used for speciated eliminators and repopulators. +#[cfg(feature = "speciation")] +pub mod speciation; + /// Used to quickly import everything this crate has to offer. /// Simply add `use genetic_rs::prelude::*` to begin using this crate. pub mod prelude; diff --git a/genetic-rs-common/src/prelude.rs b/genetic-rs-common/src/prelude.rs index bd3c19f..ade6ac9 100644 --- a/genetic-rs-common/src/prelude.rs +++ b/genetic-rs-common/src/prelude.rs @@ -5,4 +5,7 @@ pub use crate::*; #[cfg(feature = "builtin")] pub use crate::builtin::{eliminator::*, repopulator::*}; +#[cfg(feature = "speciation")] +pub use crate::speciation::Speciated; + pub use rand::prelude::*; diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs new file mode 100644 index 0000000..474f5bc --- /dev/null +++ b/genetic-rs-common/src/speciation.rs @@ -0,0 +1,73 @@ +/// Trait that allows a genome to be separated into species. +pub trait Speciated { + /// How structurally different this genome is to another genome. The higher the value, the more different they are. + /// When implementing this, you should consider only the major structural differences that could + /// take several generations to evolve an optimal solution. Do not consider minor differences that + /// are easily evolved in a single generation, such as a single weight change. + /// By common convention, this is typically a value between 0 and 1. + /// Additionally, it should also be symmetric, meaning that the divergence between genome A and genome B should be + /// the same as the divergence between genome B and genome A. + /// This value is used to separate genomes into a species. + fn divergence(&self, other: &Self) -> f32; +} + +/// A population of genomes that have been separated into species. +pub struct SpeciatedPopulation { + /// The species in this population. Each species is a vector of indices into the original genome vector. + /// The first genome in a species is its representation (i.e. the one that gets compared to other genomes to determine + /// if they belong in the species) + pub species: Vec>, + + /// The threshold used to determine if a genome belongs in a species. If the divergence between a genome and the representative genome + /// of a species is less than this threshold, then the genome belongs in that species. + pub threshold: f32, +} + +impl SpeciatedPopulation { + /// Inserts a genome into the speciated population. + /// Returns whether a new species was created by this insertion. + pub fn insert_genome(&mut self, index: usize, genomes: &[G]) -> bool { + if let Some(species) = self.get_species_mut(index, genomes) { + species.push(index); + return false; + } + self.species.push(vec![index]); + true + } + + /// Creates a new speciated population from a population of genomes. + /// Note that this can be O(n^2) worst case, but is typically much faster in practice, + /// especially if the genome structure doesn't mutate often. + pub fn from_genomes(population: &[G], threshold: f32) -> Self { + let mut speciated_population = SpeciatedPopulation { + species: Vec::new(), + threshold, + }; + for index in 0..population.len() { + speciated_population.insert_genome(index, population); + } + speciated_population + } + + /// Gets the species that a genome belongs to, if any. + pub fn get_species(&self, index: usize, genomes: &[G]) -> Option<&Vec> { + let genome = &genomes[index]; + for species in &self.species { + if genome.divergence(&genomes[species[0]]) < self.threshold { + return Some(species); + } + } + None + } + + /// Get a mutable reference to the species that a genome belongs to, if any. + pub fn get_species_mut(&mut self, index: usize, genomes: &[G]) -> Option<&mut Vec> { + let genome = &genomes[index]; + for species in &mut self.species { + if genome.divergence(&genomes[species[0]]) < self.threshold { + return Some(species); + } + } + None + } +} \ No newline at end of file From b33448ed23ae84e63c382653cfb87961b477399f Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:04:15 +0000 Subject: [PATCH 02/38] cargo fmt --- genetic-rs-common/src/builtin/eliminator.rs | 45 +++++++++++++++------ genetic-rs-common/src/speciation.rs | 8 +++- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 833aeff..3722a9d 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -527,10 +527,14 @@ pub use knockout::*; #[cfg(feature = "speciation")] mod speciation { use crate::{prelude::*, speciation::SpeciatedPopulation}; - + //// An eliminator that attempts to preserve new experimental structures by dividing a genome's /// fitness by the number of genomes in its species. - pub struct SpeciatedFitnessEliminator, G: Speciated + FeatureBoundedGenome, O: FeatureBoundedFitnessObserver = ()> { + pub struct SpeciatedFitnessEliminator< + F: FeatureBoundedFitnessFn, + G: Speciated + FeatureBoundedGenome, + O: FeatureBoundedFitnessObserver = (), + > { /// The divergence threshold used to determine whether a genome belongs in a species. /// See [`SpeciatedPopulation::threshold`] for more info. pub speciation_threshold: f32, @@ -540,7 +544,7 @@ mod speciation { /// The percentage of genomes to keep. Must be between 0.0 and 1.0. pub keep_threshold: f32, - + /// The fitness observer used to observe fitness scores. pub observer: Option, @@ -555,7 +559,12 @@ mod speciation { { /// Creates a new [`SpeciatedFitnessEliminator`] with a given fitness function and thresholds. /// Panics if the thresholds are not between 0.0 and 1.0. - pub fn new(fitness_fn: F, speciation_threshold: f32, keep_threshold: f32, observer: Option) -> Self { + pub fn new( + fitness_fn: F, + speciation_threshold: f32, + keep_threshold: f32, + observer: Option, + ) -> Self { if !(0.0..=1.0).contains(&speciation_threshold) { panic!("Speciation threshold must be between 0.0 and 1.0"); } @@ -573,8 +582,16 @@ mod speciation { /// Creates a new [`SpeciatedFitnessEliminator`] from a regular [`FitnessEliminator`] and a speciation threshold. /// Useful since you can use the builder for [`FitnessEliminator`] to construct the fitness function and observer, then convert it into a [`SpeciatedFitnessEliminator`] with this method. - pub fn from_fitness_eliminator(fitness_eliminator: FitnessEliminator, speciation_threshold: f32) -> Self { - Self::new(fitness_eliminator.fitness_fn, speciation_threshold, fitness_eliminator.threshold, Some(fitness_eliminator.observer)) + pub fn from_fitness_eliminator( + fitness_eliminator: FitnessEliminator, + speciation_threshold: f32, + ) -> Self { + Self::new( + fitness_eliminator.fitness_fn, + speciation_threshold, + fitness_eliminator.threshold, + Some(fitness_eliminator.observer), + ) } /// Calculates the fitness of each genome, dividing by the number of genomes in its species, and sorts them by fitness. @@ -593,7 +610,8 @@ mod speciation { } } - let mut fitnesses: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses.into_iter()).collect(); + let mut fitnesses: Vec<(G, f32)> = + genomes.into_iter().zip(fitnesses.into_iter()).collect(); fitnesses.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); fitnesses } @@ -603,10 +621,10 @@ mod speciation { #[cfg(feature = "rayon")] pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold); - + // Create fitnesses array with default values let mut fitnesses = vec![0.0; genomes.len()]; - + // Process species sequentially, but within each species compute fitnesses in parallel for species in population.species { let len = species.len() as f32; @@ -618,14 +636,15 @@ mod speciation { (index, fitness) }) .collect(); - + for (index, fitness) in updates { fitnesses[index] = fitness; } } - + // Pair genomes with fitnesses and sort - let mut result: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses.into_iter()).collect(); + let mut result: Vec<(G, f32)> = + genomes.into_iter().zip(fitnesses.into_iter()).collect(); result.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); result } @@ -656,4 +675,4 @@ mod speciation { } #[cfg(feature = "speciation")] -pub use speciation::*; \ No newline at end of file +pub use speciation::*; diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 474f5bc..24729a8 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -61,7 +61,11 @@ impl SpeciatedPopulation { } /// Get a mutable reference to the species that a genome belongs to, if any. - pub fn get_species_mut(&mut self, index: usize, genomes: &[G]) -> Option<&mut Vec> { + pub fn get_species_mut( + &mut self, + index: usize, + genomes: &[G], + ) -> Option<&mut Vec> { let genome = &genomes[index]; for species in &mut self.species { if genome.divergence(&genomes[species[0]]) < self.threshold { @@ -70,4 +74,4 @@ impl SpeciatedPopulation { } None } -} \ No newline at end of file +} From 1233e02097b2e098201230a7bc8316ba423c1d34 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:11:20 +0000 Subject: [PATCH 03/38] create new speciatedcrossoverrepopulator --- genetic-rs-common/src/builtin/eliminator.rs | 52 +++----- genetic-rs-common/src/builtin/repopulator.rs | 130 +++++++++---------- genetic-rs-common/src/speciation.rs | 70 ++++++++-- 3 files changed, 146 insertions(+), 106 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 3722a9d..d0945d6 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -539,14 +539,12 @@ mod speciation { /// See [`SpeciatedPopulation::threshold`] for more info. pub speciation_threshold: f32, - /// The fitness function used to evaluate genomes. - pub fitness_fn: F, - - /// The percentage of genomes to keep. Must be between 0.0 and 1.0. - pub keep_threshold: f32, + /// The inner fitness eliminator used to hold settings and such. + pub inner: FitnessEliminator, - /// The fitness observer used to observe fitness scores. - pub observer: Option, + /// The context used to determine species membership. + /// This is necessary since the eliminator needs to calculate species membership to divide fitness by species size, and some genome types may require context to calculate divergence. + pub ctx: ::Context, _marker: std::marker::PhantomData, } @@ -563,19 +561,13 @@ mod speciation { fitness_fn: F, speciation_threshold: f32, keep_threshold: f32, - observer: Option, + observer: O, + ctx: ::Context, ) -> Self { - if !(0.0..=1.0).contains(&speciation_threshold) { - panic!("Speciation threshold must be between 0.0 and 1.0"); - } - if !(0.0..=1.0).contains(&keep_threshold) { - panic!("Keep threshold must be between 0.0 and 1.0"); - } Self { - fitness_fn, speciation_threshold, - keep_threshold, - observer, + inner: FitnessEliminator::new(fitness_fn, keep_threshold, observer), + ctx, _marker: std::marker::PhantomData, } } @@ -585,27 +577,28 @@ mod speciation { pub fn from_fitness_eliminator( fitness_eliminator: FitnessEliminator, speciation_threshold: f32, + ctx: ::Context, ) -> Self { - Self::new( - fitness_eliminator.fitness_fn, + Self { speciation_threshold, - fitness_eliminator.threshold, - Some(fitness_eliminator.observer), - ) + inner: fitness_eliminator, + ctx, + _marker: std::marker::PhantomData, + } } /// Calculates the fitness of each genome, dividing by the number of genomes in its species, and sorts them by fitness. /// Returns a vector of tuples containing the genome and its fitness score. #[cfg(not(feature = "rayon"))] pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { - let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold); + let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); let mut fitnesses = vec![0.0; genomes.len()]; for species in population.species { let len = species.len() as f32; for index in species { let genome = &genomes[index]; - let fitness = self.fitness_fn.fitness(genome) / len; + let fitness = self.inner.fitness_fn.fitness(genome) / len; fitnesses[index] = fitness; } } @@ -620,19 +613,17 @@ mod speciation { /// Returns a vector of tuples containing the genome and its fitness score. #[cfg(feature = "rayon")] pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { - let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold); + let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); - // Create fitnesses array with default values let mut fitnesses = vec![0.0; genomes.len()]; - // Process species sequentially, but within each species compute fitnesses in parallel for species in population.species { let len = species.len() as f32; let updates: Vec<(usize, f32)> = species .par_iter() .map(|&index| { let genome = &genomes[index]; - let fitness = self.fitness_fn.fitness(genome) / len; + let fitness = self.inner.fitness_fn.fitness(genome) / len; (index, fitness) }) .collect(); @@ -642,7 +633,6 @@ mod speciation { } } - // Pair genomes with fitnesses and sort let mut result: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses.into_iter()).collect(); result.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); @@ -659,7 +649,7 @@ mod speciation { #[cfg(not(feature = "rayon"))] fn eliminate(&mut self, genomes: Vec) -> Vec { let mut fitnesses = self.calculate_and_sort(genomes); - let median_index = (fitnesses.len() as f32) * self.keep_threshold; + let median_index = (fitnesses.len() as f32) * self.inner.threshold; fitnesses.truncate(median_index as usize + 1); fitnesses.into_iter().map(|(g, _)| g).collect() } @@ -667,7 +657,7 @@ mod speciation { #[cfg(feature = "rayon")] fn eliminate(&mut self, genomes: Vec) -> Vec { let mut fitnesses = self.calculate_and_sort(genomes); - let median_index = (fitnesses.len() as f32) * self.keep_threshold; + let median_index = (fitnesses.len() as f32) * self.inner.threshold; fitnesses.truncate(median_index as usize + 1); fitnesses.into_par_iter().map(|(g, _)| g).collect() } diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 5f13994..25a0f30 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -157,43 +157,59 @@ pub use crossover::*; #[cfg(feature = "speciation")] mod speciation { - use std::collections::HashMap; - use rand::RngExt; + use crate::speciation::{Speciated, SpeciatedPopulation}; + use super::*; - /// Used in speciated crossover nextgens. Allows for genomes to avoid crossover with ones that are too different. - pub trait Speciated { - /// The type used to distinguish - /// one genome's species from another. - type Species: Eq + std::hash::Hash; // I really don't like that we need `Eq` when `PartialEq` better fits the definiton. + /// The action to take when a genome is found to be in a species by itself. + /// This can be used to prevent species from going extinct due to bad luck in crossover. + pub enum ActionIfIsolated { + /// Do nothing, allowing the species to go extinct if the genome is not fit enough. + DoNothing, - /// Get/calculate this genome's species. - fn species(&self) -> Self::Species; + /// Perform crossover between the genome and itself to create a new member of the species. This can help prevent extinction while still introducing some variation, but may be more computationally expensive than cloning. + SelfCrossover, } /// Repopulator that uses crossover reproduction to create new genomes, but only between genomes of the same species. pub struct SpeciatedCrossoverRepopulator { - /// The inner crossover repopulator. This holds the settings for crossover operations, - /// but may also be called if [`allow_emergency_repr`][Self::allow_emergency_repr] is `true`. - pub crossover: CrossoverRepopulator, + /// The inner crossover repopulator. This holds the settings for crossover operations. + pub inner: CrossoverRepopulator, + + /// The threshold used to determine if a genome belongs in a species. + /// See [`SpeciatedPopulation::threshold`] for more info. + pub speciation_threshold: f32, - /// Whether to allow genomes to reproduce across species boundaries - /// (effectively vanilla crossover) - /// in emergency situations where no genomes have compatible partners. - /// If disabled, the simulation will panic in such a situation. - pub allow_emergency_repr: bool, + /// The action to take when a genome is found to be in a species by itself. + pub action_if_isolated: ActionIfIsolated, + + /// Additional context for speciation. + pub ctx: ::Context, _marker: std::marker::PhantomData, } impl SpeciatedCrossoverRepopulator { /// Creates a new [`SpeciatedCrossoverRepopulator`]. - pub fn new(mutation_rate: f32, allow_emergency_repr: bool, ctx: G::Context) -> Self { + pub fn new(mutation_rate: f32, threshold: f32, action_if_isolated: ActionIfIsolated, crossover_ctx: ::Context, spec_ctx: ::Context) -> Self { + Self { + inner: CrossoverRepopulator::new(mutation_rate, crossover_ctx), + ctx: spec_ctx, + speciation_threshold: threshold, + action_if_isolated, + _marker: std::marker::PhantomData, + } + } + + /// Creates a new [`SpeciatedCrossoverRepopulator`] from an existing [`CrossoverRepopulator`], using the same mutation settings. + pub fn from_crossover_repopulator(inner: CrossoverRepopulator, threshold: f32, action_if_isolated: ActionIfIsolated, ctx: ::Context) -> Self { Self { - crossover: CrossoverRepopulator::new(mutation_rate, ctx), - allow_emergency_repr, + inner, + ctx, + speciation_threshold: threshold, + action_if_isolated, _marker: std::marker::PhantomData, } } @@ -203,60 +219,42 @@ mod speciation { where G: Crossover + Speciated, { - // i'm still not really satisfied with this implementation, - // but it's better than the old one. fn repopulate(&mut self, genomes: &mut Vec, target_size: usize) { let initial_size = genomes.len(); let mut rng = rand::rng(); - let mut species: HashMap<::Species, Vec<&G>> = HashMap::new(); - - for genome in genomes.iter() { - let spec = genome.species(); - species.entry(spec).or_insert_with(Vec::new).push(genome); - } - - let mut species_iter = species.values(); - let to_create = target_size - initial_size; - let mut new_genomes = Vec::with_capacity(to_create); - - while new_genomes.len() < to_create { - if let Some(spec) = species_iter.next() { - if spec.len() < 2 { - continue; - } - - for (i, &parent1) in spec.iter().enumerate() { - let mut j = rng.random_range(1..spec.len()); - if j == i { - j = 0; - } - let parent2 = spec[j]; - - new_genomes.push(parent1.crossover( - parent2, - &self.crossover.ctx, - self.crossover.mutation_rate, - &mut rng, - )); - } - } else { - // reached the end, reset the iterator - - if new_genomes.is_empty() { - // no genomes have compatible partners - if self.allow_emergency_repr { - self.crossover.repopulate(genomes, target_size); - return; - } else { - panic!("no genomes with common species"); + let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); + + let amount_to_make = target_size - initial_size; + let mut species_cycle = population.round_robin_enumerate(); + + let mut i = 0; + while i < amount_to_make { + let (species_i, genome_i) = species_cycle.next().unwrap(); + let species = &population.species[species_i]; + let parent1 = &genomes[genome_i]; + if species.len() < 2 { + match self.action_if_isolated { + ActionIfIsolated::DoNothing => continue, + ActionIfIsolated::SelfCrossover => { + let child = parent1.crossover(parent1, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + genomes.push(child); + i += 1; + continue; } } + } - species_iter = species.values(); + let mut j = rng.random_range(1..species.len()); + if genome_i == species[j] { + j = 0; } - } + let parent2 = &genomes[species[j]]; - genomes.extend(new_genomes); + let child = parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + genomes.push(child); + + i += 1; + } } } } diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 24729a8..2945c03 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -1,5 +1,9 @@ /// Trait that allows a genome to be separated into species. pub trait Speciated { + /// The context type for speciation. This can be used to provide additional information + /// that may be needed to calculate the divergence between two genomes. + type Context; + /// How structurally different this genome is to another genome. The higher the value, the more different they are. /// When implementing this, you should consider only the major structural differences that could /// take several generations to evolve an optimal solution. Do not consider minor differences that @@ -8,7 +12,7 @@ pub trait Speciated { /// Additionally, it should also be symmetric, meaning that the divergence between genome A and genome B should be /// the same as the divergence between genome B and genome A. /// This value is used to separate genomes into a species. - fn divergence(&self, other: &Self) -> f32; + fn divergence(&self, other: &Self, ctx: &Self::Context) -> f32; } /// A population of genomes that have been separated into species. @@ -26,8 +30,8 @@ pub struct SpeciatedPopulation { impl SpeciatedPopulation { /// Inserts a genome into the speciated population. /// Returns whether a new species was created by this insertion. - pub fn insert_genome(&mut self, index: usize, genomes: &[G]) -> bool { - if let Some(species) = self.get_species_mut(index, genomes) { + pub fn insert_genome(&mut self, index: usize, genomes: &[G], ctx: &G::Context) -> bool { + if let Some(species) = self.get_species_mut(index, genomes, ctx) { species.push(index); return false; } @@ -38,22 +42,22 @@ impl SpeciatedPopulation { /// Creates a new speciated population from a population of genomes. /// Note that this can be O(n^2) worst case, but is typically much faster in practice, /// especially if the genome structure doesn't mutate often. - pub fn from_genomes(population: &[G], threshold: f32) -> Self { + pub fn from_genomes(population: &[G], threshold: f32, ctx: &G::Context) -> Self { let mut speciated_population = SpeciatedPopulation { species: Vec::new(), threshold, }; for index in 0..population.len() { - speciated_population.insert_genome(index, population); + speciated_population.insert_genome(index, population, ctx); } speciated_population } /// Gets the species that a genome belongs to, if any. - pub fn get_species(&self, index: usize, genomes: &[G]) -> Option<&Vec> { + pub fn get_species(&self, index: usize, genomes: &[G], ctx: &G::Context) -> Option<&Vec> { let genome = &genomes[index]; for species in &self.species { - if genome.divergence(&genomes[species[0]]) < self.threshold { + if genome.divergence(&genomes[species[0]], ctx) < self.threshold { return Some(species); } } @@ -65,13 +69,61 @@ impl SpeciatedPopulation { &mut self, index: usize, genomes: &[G], + ctx: &G::Context, ) -> Option<&mut Vec> { let genome = &genomes[index]; for species in &mut self.species { - if genome.divergence(&genomes[species[0]]) < self.threshold { + if genome.divergence(&genomes[species[0]], ctx) < self.threshold { return Some(species); } } None } -} + + /// Round robin cyclic iterator over the genomes in this population. + /// Note that the index value is the index in the original genome vector, not the index in the species vector. + pub fn round_robin(&self) -> impl Iterator + '_ { + let species = &self.species; + let mut idx_in_species = vec![0; self.species.len()]; + let mut species_i = 0usize; + + std::iter::from_fn(move || { + if species.is_empty() { + return None; + } + + let cur_species = &species[species_i]; + let genome_index = cur_species[idx_in_species[species_i]]; + idx_in_species[species_i] += 1; + if idx_in_species[species_i] >= cur_species.len() { + idx_in_species[species_i] = 0; + species_i = (species_i + 1) % species.len(); + } + Some(genome_index) + }) + } + + /// Round robin, but also returns the species index along with the genome index. + /// Note that the genome index value is the index in the original genome vector, not the index in the species vector. + /// However, the species index represents the index in [`Self::species`]. + pub fn round_robin_enumerate(&self) -> impl Iterator + '_ { + let species = &self.species; + let mut idx_in_species = vec![0; self.species.len()]; + let mut species_i = 0usize; + + std::iter::from_fn(move || { + if species.is_empty() { + return None; + } + + let cur_species = &species[species_i]; + let genome_index = cur_species[idx_in_species[species_i]]; + idx_in_species[species_i] += 1; + if idx_in_species[species_i] >= cur_species.len() { + idx_in_species[species_i] = 0; + species_i = (species_i + 1) % species.len(); + } + Some((species_i, genome_index)) + }) + } +} \ No newline at end of file From a32cd23b1ac71c93e4eaf4fa5c40dd265bf2170e Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:16:48 +0000 Subject: [PATCH 04/38] doc changes --- genetic-rs-common/src/builtin/repopulator.rs | 5 ++++- genetic-rs-common/src/speciation.rs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 25a0f30..b31c970 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -167,9 +167,12 @@ mod speciation { /// This can be used to prevent species from going extinct due to bad luck in crossover. pub enum ActionIfIsolated { /// Do nothing, allowing the species to go extinct if the genome is not fit enough. + /// Note that if all species have only one member (not likely, but possible), + /// this can result in the repopulator hanging. DoNothing, - /// Perform crossover between the genome and itself to create a new member of the species. This can help prevent extinction while still introducing some variation, but may be more computationally expensive than cloning. + /// Perform crossover between the genome and itself to create a new member of the species. + /// This can help prevent species from going extinct, but can also lead to less diversity in the population. SelfCrossover, } diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 2945c03..443f10a 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -106,6 +106,7 @@ impl SpeciatedPopulation { /// Round robin, but also returns the species index along with the genome index. /// Note that the genome index value is the index in the original genome vector, not the index in the species vector. /// However, the species index represents the index in [`Self::species`]. + /// Format is (species_index, genome_index). pub fn round_robin_enumerate(&self) -> impl Iterator + '_ { let species = &self.species; let mut idx_in_species = vec![0; self.species.len()]; From c6876aeed2c732a9149abc3df90d128bbdef5dda Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:23:20 +0000 Subject: [PATCH 05/38] write speciation example --- genetic-rs-common/src/builtin/eliminator.rs | 21 +++- genetic-rs-common/src/builtin/repopulator.rs | 70 ++++++++++- genetic-rs/examples/speciation.rs | 120 ++++++++++++++----- 3 files changed, 173 insertions(+), 38 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index d0945d6..0a5ad02 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -52,6 +52,16 @@ impl FitnessObserver for () { fn observe(&mut self, _fitnesses: &[(G, f32)]) {} } +impl FitnessObserver for F +where + F: Fn(&[(G, f32)]), + G: FeatureBoundedGenome, +{ + fn observe(&mut self, fitnesses: &[(G, f32)]) { + (self)(fitnesses); + } +} + /// An observer that calls two observers in sequence. /// Created by [`FitnessObserver::layer`]. pub struct LayeredObserver, B: FitnessObserver>( @@ -596,10 +606,15 @@ mod speciation { for species in population.species { let len = species.len() as f32; + debug_assert!(len != 0.0); for index in species { let genome = &genomes[index]; - let fitness = self.inner.fitness_fn.fitness(genome) / len; - fitnesses[index] = fitness; + let fitness = self.inner.fitness_fn.fitness(genome); + if fitness < 0.0 { + fitnesses[index] = fitness * len; + } else { + fitnesses[index] = fitness / len; + } } } @@ -649,6 +664,7 @@ mod speciation { #[cfg(not(feature = "rayon"))] fn eliminate(&mut self, genomes: Vec) -> Vec { let mut fitnesses = self.calculate_and_sort(genomes); + self.inner.observer.observe(&fitnesses); let median_index = (fitnesses.len() as f32) * self.inner.threshold; fitnesses.truncate(median_index as usize + 1); fitnesses.into_iter().map(|(g, _)| g).collect() @@ -657,6 +673,7 @@ mod speciation { #[cfg(feature = "rayon")] fn eliminate(&mut self, genomes: Vec) -> Vec { let mut fitnesses = self.calculate_and_sort(genomes); + self.inner.observer.observe(&fitnesses); let median_index = (fitnesses.len() as f32) * self.inner.threshold; fitnesses.truncate(median_index as usize + 1); fitnesses.into_par_iter().map(|(g, _)| g).collect() diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index b31c970..30902b0 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -150,6 +150,16 @@ mod crossover { } } } + + impl Default for CrossoverRepopulator + where + G: Crossover, + G::Context: Default, + { + fn default() -> Self { + Self::new(0.05, G::Context::default()) + } + } } #[cfg(feature = "crossover")] @@ -173,7 +183,15 @@ mod speciation { /// Perform crossover between the genome and itself to create a new member of the species. /// This can help prevent species from going extinct, but can also lead to less diversity in the population. - SelfCrossover, + CrossoverSelf, + + /// Perform crossover between the genome and a random member of the most similar species to create a new member of the species. + /// This can help prevent species from going extinct while maintaining more diversity than self-crossover, but can also lead to more computational overhead. + CrossoverSimilarSpecies, + + /// Perform crossover between the genome and a random member of the entire population, ignoring species boundaries, to create a new member of the species. + /// This can help prevent species from going extinct, but can also contribute to more broken or dysfunctional species. + CrossoverRandom, } /// Repopulator that uses crossover reproduction to create new genomes, but only between genomes of the same species. @@ -207,7 +225,7 @@ mod speciation { } /// Creates a new [`SpeciatedCrossoverRepopulator`] from an existing [`CrossoverRepopulator`], using the same mutation settings. - pub fn from_crossover_repopulator(inner: CrossoverRepopulator, threshold: f32, action_if_isolated: ActionIfIsolated, ctx: ::Context) -> Self { + pub fn from_crossover(inner: CrossoverRepopulator, threshold: f32, action_if_isolated: ActionIfIsolated, ctx: ::Context) -> Self { Self { inner, ctx, @@ -238,11 +256,46 @@ mod speciation { if species.len() < 2 { match self.action_if_isolated { ActionIfIsolated::DoNothing => continue, - ActionIfIsolated::SelfCrossover => { + ActionIfIsolated::CrossoverSelf => { let child = parent1.crossover(parent1, &self.inner.ctx, self.inner.mutation_rate, &mut rng); genomes.push(child); i += 1; continue; + }, + ActionIfIsolated::CrossoverSimilarSpecies => { + let mut best_species_i = 0; + let mut best_divergence = f32::MAX; + for (j, species) in population.species.iter().enumerate() { + if j == species_i || species.is_empty() { + continue; + } + let representative = &genomes[species[0]]; + let divergence = parent1.divergence(representative, &self.ctx); + if divergence < best_divergence { + best_divergence = divergence; + best_species_i = j; + } + } + + let best_species = &population.species[best_species_i]; + let j = rng.random_range(0..best_species.len()); + let parent2 = &genomes[best_species[j]]; + let child = parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + genomes.push(child); + i += 1; + continue; + }, + ActionIfIsolated::CrossoverRandom => { + let mut j = rng.random_range(1..genomes.len()); + if j == genome_i { + j = 0; + } + + let parent2 = &genomes[j]; + let child = parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + genomes.push(child); + i += 1; + continue; } } } @@ -260,6 +313,17 @@ mod speciation { } } } + + impl Default for SpeciatedCrossoverRepopulator + where + G: Crossover + Speciated, + ::Context: Default, + ::Context: Default, + { + fn default() -> Self { + Self::from_crossover(CrossoverRepopulator::default(), 0.1, ActionIfIsolated::CrossoverSimilarSpecies, ::Context::default()) + } + } } #[cfg(feature = "speciation")] diff --git a/genetic-rs/examples/speciation.rs b/genetic-rs/examples/speciation.rs index 662ee45..43c2f78 100644 --- a/genetic-rs/examples/speciation.rs +++ b/genetic-rs/examples/speciation.rs @@ -1,26 +1,52 @@ use genetic_rs::prelude::*; +/// Max value of divergence() that will count two genomes as the same species. +const SPECIATION_THRESHOLD: f32 = 0.1; + #[derive(Clone, PartialEq, Debug)] struct MyGenome { - val1: f32, - val2: f32, + vals: Vec, +} + +impl GenerateRandom for MyGenome { + fn gen_random(rng: &mut impl rand::Rng) -> Self { + Self { + vals: (0..10).map(|_| rng.random_range(-1.0..1.0)).collect(), + } + } } impl RandomlyMutable for MyGenome { type Context = (); - fn mutate(&mut self, _: &(), rate: f32, rng: &mut impl Rng) { - self.val1 += rng.random_range(-1.0..1.0) * rate; - self.val2 += rng.random_range(-1.0..1.0) * rate; + fn mutate(&mut self, _ctx: &Self::Context, rate: f32, rng: &mut impl rand::Rng) { + // internal mutation + for val in &mut self.vals { + if rng.random_bool(rate as f64) { + *val += rng.random_range(-0.1..0.1); + } + } + + // structural mutations + if rng.random_bool(rate as f64) { + // add a new random gene + self.vals.push(rng.random_range(-1.0..1.0)); + } + if self.vals.len() > 1 && rng.random_bool(rate as f64) { + // remove a random gene + let index = rng.random_range(0..self.vals.len()); + self.vals.remove(index); + } } } impl Mitosis for MyGenome { type Context = (); - fn divide(&self, _: &(), rate: f32, rng: &mut impl Rng) -> Self { + fn divide(&self, ctx: &::Context, rate: f32, rng: &mut impl rand::Rng) + -> Self { let mut child = self.clone(); - child.mutate(&(), rate, rng); + child.mutate(ctx, rate, rng); child } } @@ -28,52 +54,80 @@ impl Mitosis for MyGenome { impl Crossover for MyGenome { type Context = (); - fn crossover(&self, other: &Self, _: &(), rate: f32, rng: &mut impl Rng) -> Self { - let mut child = Self { - val1: (self.val1 + other.val1) / 2., - val2: (self.val2 + other.val2) / 2., - }; - child.mutate(&(), rate, rng); + fn crossover(&self, other: &Self, ctx: &Self::Context, rate: f32, rng: &mut impl rand::Rng) -> Self { + // even though they ideally shouldn't crossover if they aren't the same length + // because of speciation, we can still allow it in emergency scenarios to prevent extinction + // and encourage more diversity, at the cost of more broken or dysfunctional species + let mut child; + let smaller; + if self.vals.len() < other.vals.len() { + child = other.clone(); + smaller = self; + } else { + child = self.clone(); + smaller = other; + } + + for i in 0..smaller.vals.len() { + if rng.random_bool(0.5) { + child.vals[i] = smaller.vals[i]; + } + } + + child.mutate(ctx, rate, rng); + child } } -#[derive(PartialEq, Eq, Hash)] -struct MySpecies(u32); - impl Speciated for MyGenome { - type Species = MySpecies; + type Context = (); - fn species(&self) -> Self::Species { - MySpecies((self.val1 + self.val2).round() as u32) + fn divergence(&self, other: &Self, _ctx: &Self::Context) -> f32 { + // distance in lengths divided by the larger length + let larger = self.vals.len().max(other.vals.len()); + // can't be 0 because of how we set up mutate() + assert!(larger != 0); + let length_diff = (self.vals.len() as isize - other.vals.len() as isize).abs() as f32; + length_diff / larger as f32 } } -impl GenerateRandom for MyGenome { - fn gen_random(rng: &mut impl Rng) -> Self { - Self { - val1: rng.random_range(-1.0..1.0), - val2: rng.random_range(-1.0..1.0), - } - } +fn fitness(genome: &MyGenome) -> f32 { + // the fitness function is just the sum of the genes, which encourages longer genomes. + // however, since the structure mutation can add negative values initially, speciation + // will help protect those genomes until they can mutate the internal state into positive values. + genome.vals.iter().sum() } -fn fitness(g: &MyGenome) -> f32 { - // train to make val1 and val2 as different as possible - (g.val1 - g.val2).abs() +fn print_fitnesses(fitnesses: &[(MyGenome, f32)]) { + // note that with SpeciatedFitnessEliminator, + // these values are divided by the number of genomes in the species. + let hi = fitnesses[0].1; + let med = fitnesses[fitnesses.len() / 2].1; + let lo = fitnesses[fitnesses.len()-1].1; + + println!("hi: {hi} med: {med} lo: {lo}"); } fn main() { let mut rng = rand::rng(); + let fitness_eliminator = FitnessEliminator::builder() + .fitness_fn(fitness) + .observer(print_fitnesses) + .build_or_panic(); + + let crossover_rep = CrossoverRepopulator::default(); + let mut sim = GeneticSim::new( Vec::gen_random(&mut rng, 100), - FitnessEliminator::new_without_observer(fitness), - // 25% mutation rate, allow cross-species reproduction in emergency scenarios - SpeciatedCrossoverRepopulator::new(0.25, true, ()), + SpeciatedFitnessEliminator::from_fitness_eliminator(fitness_eliminator, SPECIATION_THRESHOLD, ()), + SpeciatedCrossoverRepopulator::from_crossover(crossover_rep, SPECIATION_THRESHOLD, ActionIfIsolated::CrossoverSimilarSpecies, ()) ); sim.perform_generations(100); - dbg!(sim.genomes); + // rerunning multiple times should show a lot of diversity in genome length + dbg!(&sim.genomes[0]); } From f1a0fe7bca69036d9fe2ef5804a18d3dbec5a550 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:23:32 +0000 Subject: [PATCH 06/38] cargo fmt --- genetic-rs-common/src/builtin/eliminator.rs | 6 ++- genetic-rs-common/src/builtin/repopulator.rs | 55 ++++++++++++++++---- genetic-rs-common/src/speciation.rs | 20 +++++-- genetic-rs/examples/speciation.rs | 31 ++++++++--- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 0a5ad02..7ba8c1f 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -601,7 +601,8 @@ mod speciation { /// Returns a vector of tuples containing the genome and its fitness score. #[cfg(not(feature = "rayon"))] pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { - let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); + let population = + SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); let mut fitnesses = vec![0.0; genomes.len()]; for species in population.species { @@ -628,7 +629,8 @@ mod speciation { /// Returns a vector of tuples containing the genome and its fitness score. #[cfg(feature = "rayon")] pub fn calculate_and_sort(&self, genomes: Vec) -> Vec<(G, f32)> { - let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); + let population = + SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); let mut fitnesses = vec![0.0; genomes.len()]; diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 30902b0..dcb8c93 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -214,7 +214,13 @@ mod speciation { impl SpeciatedCrossoverRepopulator { /// Creates a new [`SpeciatedCrossoverRepopulator`]. - pub fn new(mutation_rate: f32, threshold: f32, action_if_isolated: ActionIfIsolated, crossover_ctx: ::Context, spec_ctx: ::Context) -> Self { + pub fn new( + mutation_rate: f32, + threshold: f32, + action_if_isolated: ActionIfIsolated, + crossover_ctx: ::Context, + spec_ctx: ::Context, + ) -> Self { Self { inner: CrossoverRepopulator::new(mutation_rate, crossover_ctx), ctx: spec_ctx, @@ -225,7 +231,12 @@ mod speciation { } /// Creates a new [`SpeciatedCrossoverRepopulator`] from an existing [`CrossoverRepopulator`], using the same mutation settings. - pub fn from_crossover(inner: CrossoverRepopulator, threshold: f32, action_if_isolated: ActionIfIsolated, ctx: ::Context) -> Self { + pub fn from_crossover( + inner: CrossoverRepopulator, + threshold: f32, + action_if_isolated: ActionIfIsolated, + ctx: ::Context, + ) -> Self { Self { inner, ctx, @@ -243,11 +254,12 @@ mod speciation { fn repopulate(&mut self, genomes: &mut Vec, target_size: usize) { let initial_size = genomes.len(); let mut rng = rand::rng(); - let population = SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); + let population = + SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); let amount_to_make = target_size - initial_size; let mut species_cycle = population.round_robin_enumerate(); - + let mut i = 0; while i < amount_to_make { let (species_i, genome_i) = species_cycle.next().unwrap(); @@ -257,11 +269,16 @@ mod speciation { match self.action_if_isolated { ActionIfIsolated::DoNothing => continue, ActionIfIsolated::CrossoverSelf => { - let child = parent1.crossover(parent1, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + let child = parent1.crossover( + parent1, + &self.inner.ctx, + self.inner.mutation_rate, + &mut rng, + ); genomes.push(child); i += 1; continue; - }, + } ActionIfIsolated::CrossoverSimilarSpecies => { let mut best_species_i = 0; let mut best_divergence = f32::MAX; @@ -280,11 +297,16 @@ mod speciation { let best_species = &population.species[best_species_i]; let j = rng.random_range(0..best_species.len()); let parent2 = &genomes[best_species[j]]; - let child = parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + let child = parent1.crossover( + parent2, + &self.inner.ctx, + self.inner.mutation_rate, + &mut rng, + ); genomes.push(child); i += 1; continue; - }, + } ActionIfIsolated::CrossoverRandom => { let mut j = rng.random_range(1..genomes.len()); if j == genome_i { @@ -292,7 +314,12 @@ mod speciation { } let parent2 = &genomes[j]; - let child = parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + let child = parent1.crossover( + parent2, + &self.inner.ctx, + self.inner.mutation_rate, + &mut rng, + ); genomes.push(child); i += 1; continue; @@ -306,7 +333,8 @@ mod speciation { } let parent2 = &genomes[species[j]]; - let child = parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); + let child = + parent1.crossover(parent2, &self.inner.ctx, self.inner.mutation_rate, &mut rng); genomes.push(child); i += 1; @@ -321,7 +349,12 @@ mod speciation { ::Context: Default, { fn default() -> Self { - Self::from_crossover(CrossoverRepopulator::default(), 0.1, ActionIfIsolated::CrossoverSimilarSpecies, ::Context::default()) + Self::from_crossover( + CrossoverRepopulator::default(), + 0.1, + ActionIfIsolated::CrossoverSimilarSpecies, + ::Context::default(), + ) } } } diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 443f10a..cb68592 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -30,7 +30,12 @@ pub struct SpeciatedPopulation { impl SpeciatedPopulation { /// Inserts a genome into the speciated population. /// Returns whether a new species was created by this insertion. - pub fn insert_genome(&mut self, index: usize, genomes: &[G], ctx: &G::Context) -> bool { + pub fn insert_genome( + &mut self, + index: usize, + genomes: &[G], + ctx: &G::Context, + ) -> bool { if let Some(species) = self.get_species_mut(index, genomes, ctx) { species.push(index); return false; @@ -54,7 +59,12 @@ impl SpeciatedPopulation { } /// Gets the species that a genome belongs to, if any. - pub fn get_species(&self, index: usize, genomes: &[G], ctx: &G::Context) -> Option<&Vec> { + pub fn get_species( + &self, + index: usize, + genomes: &[G], + ctx: &G::Context, + ) -> Option<&Vec> { let genome = &genomes[index]; for species in &self.species { if genome.divergence(&genomes[species[0]], ctx) < self.threshold { @@ -86,7 +96,7 @@ impl SpeciatedPopulation { let species = &self.species; let mut idx_in_species = vec![0; self.species.len()]; let mut species_i = 0usize; - + std::iter::from_fn(move || { if species.is_empty() { return None; @@ -111,7 +121,7 @@ impl SpeciatedPopulation { let species = &self.species; let mut idx_in_species = vec![0; self.species.len()]; let mut species_i = 0usize; - + std::iter::from_fn(move || { if species.is_empty() { return None; @@ -127,4 +137,4 @@ impl SpeciatedPopulation { Some((species_i, genome_index)) }) } -} \ No newline at end of file +} diff --git a/genetic-rs/examples/speciation.rs b/genetic-rs/examples/speciation.rs index 43c2f78..a31811f 100644 --- a/genetic-rs/examples/speciation.rs +++ b/genetic-rs/examples/speciation.rs @@ -43,8 +43,12 @@ impl RandomlyMutable for MyGenome { impl Mitosis for MyGenome { type Context = (); - fn divide(&self, ctx: &::Context, rate: f32, rng: &mut impl rand::Rng) - -> Self { + fn divide( + &self, + ctx: &::Context, + rate: f32, + rng: &mut impl rand::Rng, + ) -> Self { let mut child = self.clone(); child.mutate(ctx, rate, rng); child @@ -54,7 +58,13 @@ impl Mitosis for MyGenome { impl Crossover for MyGenome { type Context = (); - fn crossover(&self, other: &Self, ctx: &Self::Context, rate: f32, rng: &mut impl rand::Rng) -> Self { + fn crossover( + &self, + other: &Self, + ctx: &Self::Context, + rate: f32, + rng: &mut impl rand::Rng, + ) -> Self { // even though they ideally shouldn't crossover if they aren't the same length // because of speciation, we can still allow it in emergency scenarios to prevent extinction // and encourage more diversity, at the cost of more broken or dysfunctional species @@ -105,7 +115,7 @@ fn print_fitnesses(fitnesses: &[(MyGenome, f32)]) { // these values are divided by the number of genomes in the species. let hi = fitnesses[0].1; let med = fitnesses[fitnesses.len() / 2].1; - let lo = fitnesses[fitnesses.len()-1].1; + let lo = fitnesses[fitnesses.len() - 1].1; println!("hi: {hi} med: {med} lo: {lo}"); } @@ -122,8 +132,17 @@ fn main() { let mut sim = GeneticSim::new( Vec::gen_random(&mut rng, 100), - SpeciatedFitnessEliminator::from_fitness_eliminator(fitness_eliminator, SPECIATION_THRESHOLD, ()), - SpeciatedCrossoverRepopulator::from_crossover(crossover_rep, SPECIATION_THRESHOLD, ActionIfIsolated::CrossoverSimilarSpecies, ()) + SpeciatedFitnessEliminator::from_fitness_eliminator( + fitness_eliminator, + SPECIATION_THRESHOLD, + (), + ), + SpeciatedCrossoverRepopulator::from_crossover( + crossover_rep, + SPECIATION_THRESHOLD, + ActionIfIsolated::CrossoverSimilarSpecies, + (), + ), ); sim.perform_generations(100); From b3b504f3199141985ce278971ab7d24115adc866 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:26:36 +0000 Subject: [PATCH 07/38] fix clippy warnigns --- genetic-rs-common/src/builtin/eliminator.rs | 4 ++-- genetic-rs-common/src/builtin/repopulator.rs | 2 +- genetic-rs-common/src/speciation.rs | 14 ++------------ 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 7ba8c1f..4e707c7 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -538,7 +538,7 @@ pub use knockout::*; mod speciation { use crate::{prelude::*, speciation::SpeciatedPopulation}; - //// An eliminator that attempts to preserve new experimental structures by dividing a genome's + /// An eliminator that attempts to preserve new experimental structures by dividing a genome's /// fitness by the number of genomes in its species. pub struct SpeciatedFitnessEliminator< F: FeatureBoundedFitnessFn, @@ -620,7 +620,7 @@ mod speciation { } let mut fitnesses: Vec<(G, f32)> = - genomes.into_iter().zip(fitnesses.into_iter()).collect(); + genomes.into_iter().zip(fitnesses).collect(); fitnesses.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); fitnesses } diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index dcb8c93..5c2a9ce 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -255,7 +255,7 @@ mod speciation { let initial_size = genomes.len(); let mut rng = rand::rng(); let population = - SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); + SpeciatedPopulation::from_genomes(genomes, self.speciation_threshold, &self.ctx); let amount_to_make = target_size - initial_size; let mut species_cycle = population.round_robin_enumerate(); diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index cb68592..95ae24d 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -66,12 +66,7 @@ impl SpeciatedPopulation { ctx: &G::Context, ) -> Option<&Vec> { let genome = &genomes[index]; - for species in &self.species { - if genome.divergence(&genomes[species[0]], ctx) < self.threshold { - return Some(species); - } - } - None + self.species.iter().find(|species| genome.divergence(&genomes[species[0]], ctx) < self.threshold) } /// Get a mutable reference to the species that a genome belongs to, if any. @@ -82,12 +77,7 @@ impl SpeciatedPopulation { ctx: &G::Context, ) -> Option<&mut Vec> { let genome = &genomes[index]; - for species in &mut self.species { - if genome.divergence(&genomes[species[0]], ctx) < self.threshold { - return Some(species); - } - } - None + self.species.iter_mut().find(|species| genome.divergence(&genomes[species[0]], ctx) < self.threshold) } /// Round robin cyclic iterator over the genomes in this population. From b610ff582d2ec89576ab45e5e7c60865f7399358 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:29:28 +0000 Subject: [PATCH 08/38] fix rayon feature compile error --- genetic-rs-common/src/builtin/eliminator.rs | 22 ++++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 4e707c7..52e3b9a 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -636,22 +636,20 @@ mod speciation { for species in population.species { let len = species.len() as f32; - let updates: Vec<(usize, f32)> = species - .par_iter() - .map(|&index| { - let genome = &genomes[index]; - let fitness = self.inner.fitness_fn.fitness(genome) / len; - (index, fitness) - }) - .collect(); - - for (index, fitness) in updates { - fitnesses[index] = fitness; + debug_assert!(len != 0.0); + for index in species { + let genome = &genomes[index]; + let fitness = self.inner.fitness_fn.fitness(genome); + if fitness < 0.0 { + fitnesses[index] = fitness * len; + } else { + fitnesses[index] = fitness / len; + } } } let mut result: Vec<(G, f32)> = - genomes.into_iter().zip(fitnesses.into_iter()).collect(); + genomes.into_iter().zip(fitnesses).collect(); result.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); result } From 64c53141dc52bd05e0a347bc2c1938d04bc7860b Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:30:06 +0000 Subject: [PATCH 09/38] cargo fmt --- genetic-rs-common/src/builtin/eliminator.rs | 6 ++---- genetic-rs-common/src/speciation.rs | 8 ++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 52e3b9a..1b0626f 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -619,8 +619,7 @@ mod speciation { } } - let mut fitnesses: Vec<(G, f32)> = - genomes.into_iter().zip(fitnesses).collect(); + let mut fitnesses: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses).collect(); fitnesses.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); fitnesses } @@ -648,8 +647,7 @@ mod speciation { } } - let mut result: Vec<(G, f32)> = - genomes.into_iter().zip(fitnesses).collect(); + let mut result: Vec<(G, f32)> = genomes.into_iter().zip(fitnesses).collect(); result.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap()); result } diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 95ae24d..963b2a9 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -66,7 +66,9 @@ impl SpeciatedPopulation { ctx: &G::Context, ) -> Option<&Vec> { let genome = &genomes[index]; - self.species.iter().find(|species| genome.divergence(&genomes[species[0]], ctx) < self.threshold) + self.species + .iter() + .find(|species| genome.divergence(&genomes[species[0]], ctx) < self.threshold) } /// Get a mutable reference to the species that a genome belongs to, if any. @@ -77,7 +79,9 @@ impl SpeciatedPopulation { ctx: &G::Context, ) -> Option<&mut Vec> { let genome = &genomes[index]; - self.species.iter_mut().find(|species| genome.divergence(&genomes[species[0]], ctx) < self.threshold) + self.species + .iter_mut() + .find(|species| genome.divergence(&genomes[species[0]], ctx) < self.threshold) } /// Round robin cyclic iterator over the genomes in this population. From e135cf1ec1da8e05556cfe06ba59411bd34de3d1 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:27:08 +0000 Subject: [PATCH 10/38] implement comment fix --- genetic-rs/examples/speciation.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/genetic-rs/examples/speciation.rs b/genetic-rs/examples/speciation.rs index a31811f..a9f3635 100644 --- a/genetic-rs/examples/speciation.rs +++ b/genetic-rs/examples/speciation.rs @@ -112,7 +112,8 @@ fn fitness(genome: &MyGenome) -> f32 { fn print_fitnesses(fitnesses: &[(MyGenome, f32)]) { // note that with SpeciatedFitnessEliminator, - // these values are divided by the number of genomes in the species. + // these values are divided by the number of genomes in the species if positive, + // multiplied if negative. let hi = fitnesses[0].1; let med = fitnesses[fitnesses.len() / 2].1; let lo = fitnesses[fitnesses.len() - 1].1; From 424c477813cf1a32f0e6a15645dc5301b0944ee6 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:28:54 +0000 Subject: [PATCH 11/38] fix potential edge case + shitty doc comment (AI tab complete fucked me over) --- genetic-rs-common/src/builtin/eliminator.rs | 1 - genetic-rs-common/src/builtin/repopulator.rs | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 1b0626f..d9c4e08 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -566,7 +566,6 @@ mod speciation { O: FeatureBoundedFitnessObserver, { /// Creates a new [`SpeciatedFitnessEliminator`] with a given fitness function and thresholds. - /// Panics if the thresholds are not between 0.0 and 1.0. pub fn new( fitness_fn: F, speciation_threshold: f32, diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 5c2a9ce..99a46ca 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -253,6 +253,10 @@ mod speciation { { fn repopulate(&mut self, genomes: &mut Vec, target_size: usize) { let initial_size = genomes.len(); + if initial_size >= target_size { + return; + } + let mut rng = rand::rng(); let population = SpeciatedPopulation::from_genomes(genomes, self.speciation_threshold, &self.ctx); From eb97a1602b275c4b8e6dfa76bbde40ea8aadee9c Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:31:26 +0000 Subject: [PATCH 12/38] fix found robin iterator --- genetic-rs-common/src/speciation.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 963b2a9..8a630ec 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -98,11 +98,8 @@ impl SpeciatedPopulation { let cur_species = &species[species_i]; let genome_index = cur_species[idx_in_species[species_i]]; - idx_in_species[species_i] += 1; - if idx_in_species[species_i] >= cur_species.len() { - idx_in_species[species_i] = 0; - species_i = (species_i + 1) % species.len(); - } + idx_in_species[species_i] = (idx_in_species[species_i] + 1) % cur_species.len(); + species_i = (species_i + 1) % species.len(); Some(genome_index) }) } From f6ae0f3195f8792d54280d4de159cbbd0f174502 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:32:44 +0000 Subject: [PATCH 13/38] fix enumerated round robin --- genetic-rs-common/src/speciation.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 8a630ec..6dd8fcb 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -119,13 +119,11 @@ impl SpeciatedPopulation { } let cur_species = &species[species_i]; + let cur_species_i = species_i; let genome_index = cur_species[idx_in_species[species_i]]; - idx_in_species[species_i] += 1; - if idx_in_species[species_i] >= cur_species.len() { - idx_in_species[species_i] = 0; - species_i = (species_i + 1) % species.len(); - } - Some((species_i, genome_index)) + idx_in_species[species_i] = (idx_in_species[species_i] + 1) % cur_species.len(); + species_i = (species_i + 1) % species.len(); + Some((cur_species_i, genome_index)) }) } } From 362de191b447cffb4117c97385f903ef3b50b20f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:41:59 +0000 Subject: [PATCH 14/38] Initial plan From 853ab6cbc1c6761c277a5072793bb411756c8de0 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:42:36 +0000 Subject: [PATCH 15/38] fix CrossoverRandom affecting new genomes --- genetic-rs-common/src/builtin/repopulator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 99a46ca..afe580c 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -312,7 +312,7 @@ mod speciation { continue; } ActionIfIsolated::CrossoverRandom => { - let mut j = rng.random_range(1..genomes.len()); + let mut j = rng.random_range(1..initial_size); if j == genome_i { j = 0; } From 820b77f181b5c14e49f473de37598642c9f927c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:44:39 +0000 Subject: [PATCH 16/38] Fix ActionIfIsolated::DoNothing hang when all species are isolated Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs-common/src/builtin/repopulator.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 99a46ca..3fad2a8 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -177,8 +177,9 @@ mod speciation { /// This can be used to prevent species from going extinct due to bad luck in crossover. pub enum ActionIfIsolated { /// Do nothing, allowing the species to go extinct if the genome is not fit enough. - /// Note that if all species have only one member (not likely, but possible), - /// this can result in the repopulator hanging. + /// If *all* species happen to be isolated (no species has ≥ 2 members), the + /// repopulator automatically falls back to [`CrossoverRepopulator`] to avoid + /// an infinite loop. DoNothing, /// Perform crossover between the genome and itself to create a new member of the species. @@ -261,6 +262,17 @@ mod speciation { let population = SpeciatedPopulation::from_genomes(genomes, self.speciation_threshold, &self.ctx); + // When DoNothing is selected, the loop skips isolated species without + // making progress. If *all* species are isolated there are no eligible + // pairs and the loop would never terminate. Fall back to the inner + // repopulator in that case. + if matches!(self.action_if_isolated, ActionIfIsolated::DoNothing) + && !population.species.iter().any(|s| s.len() >= 2) + { + self.inner.repopulate(genomes, target_size); + return; + } + let amount_to_make = target_size - initial_size; let mut species_cycle = population.round_robin_enumerate(); From 12340c1dffe24a71cdf9d815f84eebb76cb64c60 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:52:16 +0000 Subject: [PATCH 17/38] fix doc comment --- genetic-rs-common/src/builtin/repopulator.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 3fad2a8..624719c 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -177,7 +177,7 @@ mod speciation { /// This can be used to prevent species from going extinct due to bad luck in crossover. pub enum ActionIfIsolated { /// Do nothing, allowing the species to go extinct if the genome is not fit enough. - /// If *all* species happen to be isolated (no species has ≥ 2 members), the + /// If all species happen to be isolated (no species has >= 2 members), the /// repopulator automatically falls back to [`CrossoverRepopulator`] to avoid /// an infinite loop. DoNothing, @@ -262,10 +262,7 @@ mod speciation { let population = SpeciatedPopulation::from_genomes(genomes, self.speciation_threshold, &self.ctx); - // When DoNothing is selected, the loop skips isolated species without - // making progress. If *all* species are isolated there are no eligible - // pairs and the loop would never terminate. Fall back to the inner - // repopulator in that case. + // if all species are isolated, we fall back to the inner crossover repopulator to avoid an infinite loop. if matches!(self.action_if_isolated, ActionIfIsolated::DoNothing) && !population.species.iter().any(|s| s.len() >= 2) { From d5133cc9fb65e9312ffa2dfd8675e0235146f938 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:02:12 +0000 Subject: [PATCH 18/38] create Vec::from_parent qol function --- genetic-rs-common/src/builtin/repopulator.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 52f9e81..18f7cda 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -84,6 +84,23 @@ where } } +/// Helper trait for creating a new population from a parent genome. This is a shortcut for using [`MitosisRepopulator`] +/// and is convenient for things like resuming a simulation from a deserialized elite. +pub trait FromParent { + /// Create a new population from a parent genome and a count of how many genomes to create. + /// The new population should be created by mutating the parent genome (i.e. [`Mitosis`]). + fn from_parent(parent: G, count: usize, ctx: G::Context, rate: f32) -> Self; +} + +impl FromParent for Vec { + fn from_parent(parent: G, count: usize, ctx: G::Context, rate: f32) -> Self { + let mut repopulator = MitosisRepopulator::new(rate, ctx); + let mut genomes = vec![parent]; + repopulator.repopulate(&mut genomes, count); + genomes + } +} + #[cfg(feature = "crossover")] mod crossover { use rand::RngExt; From 26a8d69bbf6d32921bbd3f651c6086e4fbaadf44 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:04:04 +0000 Subject: [PATCH 19/38] update doc comment --- genetic-rs-common/src/builtin/repopulator.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 18f7cda..f4c3c88 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -89,6 +89,8 @@ where pub trait FromParent { /// Create a new population from a parent genome and a count of how many genomes to create. /// The new population should be created by mutating the parent genome (i.e. [`Mitosis`]). + /// The function is essentially a shortcut for creating a new [`MitosisRepopulator`] and using + /// it to repopulate a new population from the parent genome. fn from_parent(parent: G, count: usize, ctx: G::Context, rate: f32) -> Self; } From a7b8d8f55ea27b784e8764a6ffcd1b63912e0cb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:04:35 +0000 Subject: [PATCH 20/38] Initial plan From 29238bac5a18d20169f448f51f736fb5d5405d3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:08:07 +0000 Subject: [PATCH 21/38] Initial plan From 15a3dd06ba3e79a66ee17d45ab881f6d4b56271c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:11:56 +0000 Subject: [PATCH 22/38] Add examples to CI workflow and register missing example metadata in Cargo.toml Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- .github/workflows/ci-cd.yml | 18 ++++++++++++++++++ genetic-rs/Cargo.toml | 12 ++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index c999d27..43c103f 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -24,6 +24,24 @@ jobs: - name: Run Clippy run: cargo clippy --all-features -- -D warnings + examples: + runs-on: ubuntu-latest + name: Run Examples + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Run readme_ex example + run: cargo run --example readme_ex --features builtin,genrand + - name: Run crossover example + run: cargo run --example crossover --features crossover,genrand + - name: Run knockout example + run: cargo run --example knockout --features knockout,crossover,genrand + - name: Run derive example + run: cargo run --example derive --features derive + - name: Run speciation example + run: cargo run --example speciation --features speciation,genrand + fmt: runs-on: ubuntu-latest name: Formatting Checks diff --git a/genetic-rs/Cargo.toml b/genetic-rs/Cargo.toml index 25d76c3..e9114f0 100644 --- a/genetic-rs/Cargo.toml +++ b/genetic-rs/Cargo.toml @@ -35,13 +35,21 @@ unexpected_cfgs = { level = "allow", check-cfg = ["cfg(publish)", "cfg(docsrs)"] [dev-dependencies] rand = "0.10.0" +[[example]] +name = "readme_ex" +required-features = ["builtin", "genrand"] + [[example]] name = "crossover" -required-features = ["crossover"] +required-features = ["crossover", "genrand"] + +[[example]] +name = "knockout" +required-features = ["knockout", "crossover", "genrand"] [[example]] name = "speciation" -required-features = ["speciation"] +required-features = ["speciation", "genrand"] [[example]] name = "derive" From b1190ffb11c0da2042888724a78df3fa7e4dcfc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:14:04 +0000 Subject: [PATCH 23/38] Add Default, Clone, Debug, PartialEq, Eq trait impls to LayeredObserver Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs-common/src/builtin/eliminator.rs | 50 +++++++++++++++++++ genetic-rs/tests/eliminator.rs | 54 +++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index d9c4e08..5309d4e 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -81,6 +81,56 @@ where } } +impl Default for LayeredObserver +where + A: Default + FitnessObserver, + B: Default + FitnessObserver, +{ + fn default() -> Self { + Self(A::default(), B::default(), std::marker::PhantomData) + } +} + +impl Clone for LayeredObserver +where + A: Clone + FitnessObserver, + B: Clone + FitnessObserver, +{ + fn clone(&self) -> Self { + Self(self.0.clone(), self.1.clone(), std::marker::PhantomData) + } +} + +impl std::fmt::Debug for LayeredObserver +where + A: std::fmt::Debug + FitnessObserver, + B: std::fmt::Debug + FitnessObserver, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("LayeredObserver") + .field(&self.0) + .field(&self.1) + .finish() + } +} + +impl PartialEq for LayeredObserver +where + A: PartialEq + FitnessObserver, + B: PartialEq + FitnessObserver, +{ + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 && self.1 == other.1 + } +} + +impl Eq for LayeredObserver +where + A: Eq + FitnessObserver, + B: Eq + FitnessObserver, +{ +} + #[cfg(not(feature = "rayon"))] #[doc(hidden)] pub trait FeatureBoundedFitnessObserver: FitnessObserver {} diff --git a/genetic-rs/tests/eliminator.rs b/genetic-rs/tests/eliminator.rs index f4c2b26..8ea1dd2 100644 --- a/genetic-rs/tests/eliminator.rs +++ b/genetic-rs/tests/eliminator.rs @@ -12,6 +12,15 @@ impl<'a, G> FitnessObserver for CountingObserver<'a> { } } +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct TrackingObserver(usize); + +impl FitnessObserver for TrackingObserver { + fn observe(&mut self, _fitnesses: &[(G, f32)]) { + self.0 += 1; + } +} + #[test] fn layered_observer_calls_both() { let count_a = Cell::new(0usize); @@ -43,3 +52,48 @@ fn layered_observer_triple_layer() { assert_eq!(count_b.get(), 1); assert_eq!(count_c.get(), 1); } + +#[test] +fn layered_observer_default() { + let layered: LayeredObserver<(), TrackingObserver, TrackingObserver> = + LayeredObserver::default(); + assert_eq!(layered.0, TrackingObserver(0)); + assert_eq!(layered.1, TrackingObserver(0)); +} + +#[test] +fn layered_observer_clone() { + let original = TrackingObserver::default().layer(TrackingObserver::default()); + let mut cloned: LayeredObserver<(), _, _> = original.clone(); + + let fitnesses: Vec<((), f32)> = vec![((), 1.0)]; + cloned.observe(&fitnesses); + + assert_eq!(cloned.0, TrackingObserver(1)); + assert_eq!(cloned.1, TrackingObserver(1)); + assert_eq!(original.0, TrackingObserver(0)); + assert_eq!(original.1, TrackingObserver(0)); +} + +#[test] +fn layered_observer_debug() { + let layered: LayeredObserver<(), TrackingObserver, TrackingObserver> = + TrackingObserver::default().layer(TrackingObserver::default()); + let debug_str = format!("{:?}", layered); + assert!(debug_str.contains("LayeredObserver")); + assert!(debug_str.contains("TrackingObserver")); +} + +#[test] +fn layered_observer_partial_eq() { + let a: LayeredObserver<(), _, _> = TrackingObserver::default().layer(TrackingObserver::default()); + let b: LayeredObserver<(), _, _> = TrackingObserver::default().layer(TrackingObserver::default()); + assert_eq!(a, b); +} + +#[test] +fn layered_observer_partial_eq_not_equal() { + let a: LayeredObserver<(), _, _> = TrackingObserver(1).layer(TrackingObserver(0)); + let b: LayeredObserver<(), _, _> = TrackingObserver(0).layer(TrackingObserver(0)); + assert_ne!(a, b); +} From a7bfefe58206b9bfe663ff378d6fd5839cb86507 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:15:41 +0000 Subject: [PATCH 24/38] cargo fmt --- genetic-rs/tests/eliminator.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/genetic-rs/tests/eliminator.rs b/genetic-rs/tests/eliminator.rs index 8ea1dd2..8738c7e 100644 --- a/genetic-rs/tests/eliminator.rs +++ b/genetic-rs/tests/eliminator.rs @@ -86,8 +86,10 @@ fn layered_observer_debug() { #[test] fn layered_observer_partial_eq() { - let a: LayeredObserver<(), _, _> = TrackingObserver::default().layer(TrackingObserver::default()); - let b: LayeredObserver<(), _, _> = TrackingObserver::default().layer(TrackingObserver::default()); + let a: LayeredObserver<(), _, _> = + TrackingObserver::default().layer(TrackingObserver::default()); + let b: LayeredObserver<(), _, _> = + TrackingObserver::default().layer(TrackingObserver::default()); assert_eq!(a, b); } From 2641263c9129fb6378106efc3a08c1c65cc53d4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:29:29 +0000 Subject: [PATCH 25/38] Add comprehensive integration test suite covering all major invariants Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs/Cargo.toml | 16 ++ genetic-rs/tests/knockout.rs | 155 +++++++++++++++++ genetic-rs/tests/repopulator.rs | 154 +++++++++++++++++ genetic-rs/tests/simulation.rs | 226 ++++++++++++++++++++++++ genetic-rs/tests/speciation.rs | 295 ++++++++++++++++++++++++++++++++ 5 files changed, 846 insertions(+) create mode 100644 genetic-rs/tests/knockout.rs create mode 100644 genetic-rs/tests/repopulator.rs create mode 100644 genetic-rs/tests/simulation.rs create mode 100644 genetic-rs/tests/speciation.rs diff --git a/genetic-rs/Cargo.toml b/genetic-rs/Cargo.toml index e9114f0..58d03c7 100644 --- a/genetic-rs/Cargo.toml +++ b/genetic-rs/Cargo.toml @@ -55,6 +55,22 @@ required-features = ["speciation", "genrand"] name = "derive" required-features = ["derive"] +[[test]] +name = "simulation" +required-features = ["builtin", "genrand"] + +[[test]] +name = "knockout" +required-features = ["knockout", "genrand"] + +[[test]] +name = "repopulator" +required-features = ["crossover", "genrand"] + +[[test]] +name = "speciation" +required-features = ["speciation", "genrand"] + [[test]] name = "derive_macros" required-features = ["derive", "genrand", "crossover"] diff --git a/genetic-rs/tests/knockout.rs b/genetic-rs/tests/knockout.rs new file mode 100644 index 0000000..db950ef --- /dev/null +++ b/genetic-rs/tests/knockout.rs @@ -0,0 +1,155 @@ +//! Integration tests for [`KnockoutEliminator`], [`KnockoutWinner`], and +//! related types. + +use std::cmp::Ordering; + +use genetic_rs::prelude::*; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test genome +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct Genome(f32); + +impl GenerateRandom for Genome { + fn gen_random(rng: &mut impl rand::Rng) -> Self { + Self(rng.random()) + } +} + +/// Knockout function that keeps the genome with the smaller value. +/// `a.total_cmp(&b)` returns `Less` when a < b, mapping to `KnockoutWinner::First` (a survives). +fn smaller_wins(a: &Genome, b: &Genome) -> KnockoutWinner { + a.0.total_cmp(&b.0).into() +} + +// ───────────────────────────────────────────────────────────────────────────── +// KnockoutWinner helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// `!First == Second` and `!Second == First`. +#[test] +fn knockout_winner_not_impl() { + assert_eq!(!KnockoutWinner::First, KnockoutWinner::Second); + assert_eq!(!KnockoutWinner::Second, KnockoutWinner::First); +} + +/// `From` must map correctly. +#[test] +fn knockout_winner_from_ordering() { + assert_eq!(KnockoutWinner::from(Ordering::Less), KnockoutWinner::First); + assert_eq!(KnockoutWinner::from(Ordering::Equal), KnockoutWinner::First); + assert_eq!( + KnockoutWinner::from(Ordering::Greater), + KnockoutWinner::Second + ); +} + +/// `Into`: `First` → 0, `Second` → 1. +#[test] +fn knockout_winner_into_usize() { + assert_eq!(usize::from(KnockoutWinner::First), 0usize); + assert_eq!(usize::from(KnockoutWinner::Second), 1usize); +} + +// ───────────────────────────────────────────────────────────────────────────── +// KnockoutEliminator output-size invariants +// ───────────────────────────────────────────────────────────────────────────── + +/// With an even-sized population the output must be exactly half. +#[test] +fn knockout_output_half_size_even_input() { + let genomes: Vec = (0..10).map(|i| Genome(i as f32)).collect(); + let mut elim = KnockoutEliminator::new(smaller_wins, ActionIfOdd::Panic); + let survivors = elim.eliminate(genomes); + assert_eq!(survivors.len(), 5); +} + +/// `ActionIfOdd::KeepSingle` with 11 genomes: 1 bypasses contest + 5 winners = 6. +#[test] +fn knockout_action_if_odd_keep_single() { + let genomes: Vec = (0..11).map(|i| Genome(i as f32)).collect(); + let mut elim = KnockoutEliminator::new(smaller_wins, ActionIfOdd::KeepSingle); + let survivors = elim.eliminate(genomes); + assert_eq!(survivors.len(), 6); +} + +/// `ActionIfOdd::DeleteSingle` with 11 genomes: 1 is discarded + 5 winners = 5. +#[test] +fn knockout_action_if_odd_delete_single() { + let genomes: Vec = (0..11).map(|i| Genome(i as f32)).collect(); + let mut elim = KnockoutEliminator::new(smaller_wins, ActionIfOdd::DeleteSingle); + let survivors = elim.eliminate(genomes); + assert_eq!(survivors.len(), 5); +} + +/// `ActionIfOdd::Panic` must panic when given an odd number of genomes. +#[test] +#[should_panic] +fn knockout_action_if_odd_panic_panics() { + let genomes: Vec = (0..11).map(|i| Genome(i as f32)).collect(); + let mut elim = KnockoutEliminator::new(smaller_wins, ActionIfOdd::Panic); + let _ = elim.eliminate(genomes); +} + +/// With a population of 1, [`KnockoutEliminator`] must return it unchanged. +#[test] +fn knockout_single_genome_returns_unchanged() { + let genomes = vec![Genome(42.0)]; + let mut elim = KnockoutEliminator::new(smaller_wins, ActionIfOdd::Panic); + let survivors = elim.eliminate(genomes); + assert_eq!(survivors.len(), 1); + assert!((survivors[0].0 - 42.0).abs() < 1e-6); +} + +// ───────────────────────────────────────────────────────────────────────────── +// KnockoutEliminator correctness +// ───────────────────────────────────────────────────────────────────────────── + +/// In each pair `(a, b)` the correct genome must survive based on the knockout fn. +/// +/// With genomes [0, 1, 2, …, 9] and `smaller_wins`: +/// - Pairs: (0,1), (2,3), (4,5), (6,7), (8,9) +/// - `a < b` → `Less` → `First` → a survives → even-indexed values survive. +#[test] +fn knockout_correct_genome_survives() { + let genomes: Vec = (0..10).map(|i| Genome(i as f32)).collect(); + let mut elim = KnockoutEliminator::new(smaller_wins, ActionIfOdd::Panic); + let survivors = elim.eliminate(genomes); + + for g in &survivors { + assert!( + (g.0 as i32) % 2 == 0, + "expected even (smaller) value to survive each pair, got {}", + g.0 + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// FitnessKnockoutFn +// ───────────────────────────────────────────────────────────────────────────── + +/// [`FitnessKnockoutFn`] must delegate to the inner fitness function and +/// produce a consistent result based on the `afit.total_cmp(&bfit)` comparison. +/// +/// The `From` mapping is: +/// - `Less` → `First` (a survives — a had lower fitness) +/// - `Greater` → `Second` (b survives — b had lower fitness) +/// +/// This tests the actual implementation behaviour rather than any particular +/// notion of "higher fitness wins"; it guards against regressions in the +/// delegation and ordering logic. +#[test] +fn fitness_knockout_fn_delegates_to_fitness_fn() { + let ko_fn = FitnessKnockoutFn::new(|g: &Genome| g.0); + let a = Genome(1.0); + let b = Genome(9.0); + + // 1.0.total_cmp(&9.0) = Less → First (a survives) + assert_eq!(ko_fn.knockout(&a, &b), KnockoutWinner::First); + + // 9.0.total_cmp(&1.0) = Greater → Second (b survives) + assert_eq!(ko_fn.knockout(&b, &a), KnockoutWinner::Second); +} diff --git a/genetic-rs/tests/repopulator.rs b/genetic-rs/tests/repopulator.rs new file mode 100644 index 0000000..5ac2462 --- /dev/null +++ b/genetic-rs/tests/repopulator.rs @@ -0,0 +1,154 @@ +//! Integration tests for [`MitosisRepopulator`], [`CrossoverRepopulator`], +//! and the [`FromParent`] helper. + +use genetic_rs::prelude::*; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test genome +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +struct Genome(f32); + +impl GenerateRandom for Genome { + fn gen_random(rng: &mut impl rand::Rng) -> Self { + Self(rng.random()) + } +} + +impl RandomlyMutable for Genome { + type Context = (); + + fn mutate(&mut self, _: &(), rate: f32, rng: &mut impl rand::Rng) { + self.0 += rng.random::() * rate; + } +} + +impl Mitosis for Genome { + type Context = (); + + fn divide(&self, ctx: &(), rate: f32, rng: &mut impl rand::Rng) -> Self { + let mut child = self.clone(); + child.mutate(ctx, rate, rng); + child + } +} + +/// Deterministic crossover: the child is exactly the average of its parents. +/// A zero mutation rate means no random component is added. +impl Crossover for Genome { + type Context = (); + + fn crossover(&self, other: &Self, _: &(), _rate: f32, _rng: &mut impl rand::Rng) -> Self { + Self((self.0 + other.0) / 2.0) + } +} + +fn fitness(g: &Genome) -> f32 { + g.0 +} + +// ───────────────────────────────────────────────────────────────────────────── +// MitosisRepopulator +// ───────────────────────────────────────────────────────────────────────────── + +/// The repopulator must grow the population back to the target size. +#[test] +fn mitosis_repopulator_fills_to_target() { + let mut rng = rand::rng(); + let mut genomes: Vec = Vec::gen_random(&mut rng, 5); + MitosisRepopulator::new(0.1, ()).repopulate(&mut genomes, 20); + assert_eq!(genomes.len(), 20); +} + +/// If the population is already at the target, it must not grow further. +#[test] +fn mitosis_repopulator_no_op_when_at_target() { + let mut rng = rand::rng(); + let mut genomes: Vec = Vec::gen_random(&mut rng, 10); + MitosisRepopulator::new(0.1, ()).repopulate(&mut genomes, 10); + assert_eq!(genomes.len(), 10); +} + +/// At mutation rate 0.0 each child must be an exact clone of its parent. +#[test] +fn mitosis_zero_mutation_rate_produces_clone() { + let parent = Genome(42.0); + let mut rng = rand::rng(); + let child = parent.divide(&(), 0.0, &mut rng); + assert_eq!(child, parent, "child at rate 0.0 must be identical to parent"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// CrossoverRepopulator +// ───────────────────────────────────────────────────────────────────────────── + +/// The crossover repopulator must grow the population back to the target size. +#[test] +fn crossover_repopulator_fills_to_target() { + let mut rng = rand::rng(); + let mut genomes: Vec = Vec::gen_random(&mut rng, 5); + CrossoverRepopulator::new(0.1, ()).repopulate(&mut genomes, 20); + assert_eq!(genomes.len(), 20); +} + +/// If the population is already at the target, it must not grow further. +#[test] +fn crossover_repopulator_no_op_when_at_target() { + let mut rng = rand::rng(); + let mut genomes: Vec = Vec::gen_random(&mut rng, 10); + CrossoverRepopulator::new(0.1, ()).repopulate(&mut genomes, 10); + assert_eq!(genomes.len(), 10); +} + +/// Crossover child must equal the average of its parents when mutation rate is 0. +#[test] +fn crossover_child_is_average_of_parents() { + let p1 = Genome(2.0); + let p2 = Genome(4.0); + let mut rng = rand::rng(); + let child = p1.crossover(&p2, &(), 0.0, &mut rng); + assert!( + (child.0 - 3.0).abs() < 1e-6, + "expected child value 3.0, got {}", + child.0 + ); +} + +/// Population size stays constant when `GeneticSim` uses crossover over many generations. +#[test] +fn population_size_constant_with_crossover_sim() { + let mut rng = rand::rng(); + let initial_size = 30; + let mut sim = GeneticSim::new( + Vec::::gen_random(&mut rng, initial_size), + FitnessEliminator::new_without_observer(fitness), + CrossoverRepopulator::new(0.05, ()), + ); + sim.perform_generations(50); + assert_eq!(sim.genomes.len(), initial_size); +} + +// ───────────────────────────────────────────────────────────────────────────── +// FromParent +// ───────────────────────────────────────────────────────────────────────────── + +/// [`Vec::from_parent`] must produce exactly the requested number of genomes. +#[test] +fn from_parent_creates_correct_count() { + let parent = Genome(1.0); + let population = Vec::::from_parent(parent, 15, (), 0.0); + assert_eq!(population.len(), 15); +} + +/// The original parent must be the first element of the returned population. +#[test] +fn from_parent_includes_original_parent() { + let parent = Genome(7.0); + let population = Vec::::from_parent(parent.clone(), 5, (), 0.0); + assert_eq!( + population[0], + parent, + "the first element must be the original parent" + ); +} diff --git a/genetic-rs/tests/simulation.rs b/genetic-rs/tests/simulation.rs new file mode 100644 index 0000000..01c4005 --- /dev/null +++ b/genetic-rs/tests/simulation.rs @@ -0,0 +1,226 @@ +//! Integration tests for [`GeneticSim`] core invariants, [`FitnessEliminator`] behaviour, +//! and helper types such as [`FitnessObserver`]. + +use genetic_rs::prelude::*; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test genome +// ───────────────────────────────────────────────────────────────────────────── + +/// A simple genome whose fitness is just its value. +#[derive(Clone, Debug)] +struct Genome(f32); + +impl GenerateRandom for Genome { + fn gen_random(rng: &mut impl rand::Rng) -> Self { + Self(rng.random()) + } +} + +impl RandomlyMutable for Genome { + type Context = (); + + fn mutate(&mut self, _: &(), rate: f32, rng: &mut impl rand::Rng) { + self.0 += rng.random::() * rate; + } +} + +impl Mitosis for Genome { + type Context = (); + + fn divide(&self, ctx: &(), rate: f32, rng: &mut impl rand::Rng) -> Self { + let mut child = self.clone(); + child.mutate(ctx, rate, rng); + child + } +} + +fn fitness(g: &Genome) -> f32 { + g.0 +} + +// ───────────────────────────────────────────────────────────────────────────── +// GeneticSim population-size invariants +// ───────────────────────────────────────────────────────────────────────────── + +/// The population size must be identical before and after a single generation. +#[test] +fn population_size_preserved_after_next_generation() { + let mut rng = rand::rng(); + let initial_size = 20; + let mut sim = GeneticSim::new( + Vec::::gen_random(&mut rng, initial_size), + FitnessEliminator::new_without_observer(fitness), + MitosisRepopulator::new(0.1, ()), + ); + sim.next_generation(); + assert_eq!(sim.genomes.len(), initial_size); +} + +/// The population size must remain constant over many generations. +#[test] +fn population_size_preserved_over_many_generations() { + let mut rng = rand::rng(); + let initial_size = 50; + let mut sim = GeneticSim::new( + Vec::::gen_random(&mut rng, initial_size), + FitnessEliminator::new_without_observer(fitness), + MitosisRepopulator::new(0.1, ()), + ); + sim.perform_generations(100); + assert_eq!(sim.genomes.len(), initial_size); +} + +// ───────────────────────────────────────────────────────────────────────────── +// GenerateRandom / GenerateRandomCollection +// ───────────────────────────────────────────────────────────────────────────── + +/// [`Vec::gen_random`] must produce exactly the requested number of genomes. +#[test] +fn gen_random_collection_produces_correct_size() { + let mut rng = rand::rng(); + for size in [1usize, 10, 100] { + let population: Vec = Vec::gen_random(&mut rng, size); + assert_eq!( + population.len(), + size, + "expected {size} genomes, got {}", + population.len() + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// FitnessEliminator elimination behaviour +// ───────────────────────────────────────────────────────────────────────────── + +/// With the default threshold (0.5) and 10 genomes, exactly 6 must survive and +/// all survivors must be in the top half by fitness. +#[test] +fn fitness_eliminator_keeps_top_by_fitness() { + // Genomes with distinct, known fitness values 0.0 … 9.0. + let genomes: Vec = (0..10).map(|i| Genome(i as f32)).collect(); + let mut eliminator = FitnessEliminator::new_without_observer(fitness); + let survivors = eliminator.eliminate(genomes); + + // threshold=0.5, n=10 → floor(10*0.5) + 1 = 6 survivors. + assert_eq!(survivors.len(), 6, "expected 6 survivors with default threshold"); + + // Every survivor must be in the top half (fitness ≥ 4.0). + for g in &survivors { + assert!( + g.0 >= 4.0, + "genome with fitness {} survived but is below the top-half threshold", + g.0, + ); + } +} + +/// The genome with the highest fitness must always survive elimination. +#[test] +fn fitness_eliminator_highest_fitness_always_survives() { + let genomes: Vec = (0..20).map(|i| Genome(i as f32)).collect(); + let mut eliminator = FitnessEliminator::new_without_observer(fitness); + let survivors = eliminator.eliminate(genomes); + assert!( + survivors.iter().any(|g| g.0 == 19.0), + "the highest-fitness genome (19.0) must always survive" + ); +} + +/// The genome with the lowest fitness must always be eliminated. +#[test] +fn fitness_eliminator_lowest_fitness_never_survives() { + let genomes: Vec = (0..20).map(|i| Genome(i as f32)).collect(); + let mut eliminator = FitnessEliminator::new_without_observer(fitness); + let survivors = eliminator.eliminate(genomes); + assert!( + !survivors.iter().any(|g| g.0 == 0.0), + "the lowest-fitness genome (0.0) must always be eliminated" + ); +} + +/// [`FitnessEliminator::calculate_and_sort`] must return genomes sorted in +/// descending order of fitness. +#[test] +fn fitness_eliminator_sorts_descending() { + let genomes = vec![Genome(3.0), Genome(1.0), Genome(4.0), Genome(2.0)]; + let eliminator = FitnessEliminator::new_without_observer(fitness); + let sorted = eliminator.calculate_and_sort(genomes); + + for window in sorted.windows(2) { + assert!( + window[0].1 >= window[1].1, + "fitness values are not in descending order: {} before {}", + window[0].1, + window[1].1, + ); + } +} + +/// A custom threshold controls the exact fraction of the population kept. +#[test] +fn fitness_eliminator_custom_threshold() { + // 10 genomes, threshold=0.3 → floor(10*0.3) + 1 = 4 survivors. + let genomes: Vec = (0..10).map(|i| Genome(i as f32)).collect(); + let mut eliminator = FitnessEliminator::new(fitness, 0.3, ()); + let survivors = eliminator.eliminate(genomes); + assert_eq!(survivors.len(), 4, "expected 4 survivors with threshold=0.3"); +} + +/// [`FitnessEliminator::new`] must panic when the threshold is outside [0, 1]. +#[test] +#[should_panic] +fn fitness_eliminator_invalid_threshold_panics() { + FitnessEliminator::new(fitness, 1.5_f32, ()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// FitnessEliminator builder +// ───────────────────────────────────────────────────────────────────────────── + +/// The builder pattern must produce an eliminator with the specified settings. +#[test] +fn fitness_eliminator_builder() { + let mut rng = rand::rng(); + let mut eliminator: FitnessEliminator<_, Genome, ()> = FitnessEliminator::builder() + .fitness_fn(fitness) + .threshold(0.4) + .build(); + + let genomes: Vec = Vec::gen_random(&mut rng, 10); + let _ = eliminator.eliminate(genomes); + assert!((eliminator.threshold - 0.4).abs() < 1e-6); +} + +// ───────────────────────────────────────────────────────────────────────────── +// FitnessObserver +// ───────────────────────────────────────────────────────────────────────────── + +/// A counting observer used below. +struct Counter(usize); + +impl FitnessObserver for Counter { + fn observe(&mut self, _: &[(Genome, f32)]) { + self.0 += 1; + } +} + +/// The observer must be called exactly once per generation. +#[test] +fn observer_called_once_per_generation() { + let mut rng = rand::rng(); + let eliminator = FitnessEliminator::new(fitness, 0.5, Counter(0)); + let mut sim = GeneticSim::new( + Vec::::gen_random(&mut rng, 10), + eliminator, + MitosisRepopulator::new(0.0, ()), + ); + + sim.perform_generations(7); + assert_eq!( + sim.eliminator.observer.0, + 7, + "observer must be called once per generation" + ); +} diff --git a/genetic-rs/tests/speciation.rs b/genetic-rs/tests/speciation.rs new file mode 100644 index 0000000..db28e1a --- /dev/null +++ b/genetic-rs/tests/speciation.rs @@ -0,0 +1,295 @@ +//! Integration tests for [`SpeciatedPopulation`], [`SpeciatedFitnessEliminator`], +//! and [`SpeciatedCrossoverRepopulator`]. + +use genetic_rs::prelude::*; +use genetic_rs::speciation::SpeciatedPopulation; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared test genome +// ───────────────────────────────────────────────────────────────────────────── + +/// A genome whose species membership is fully determined by its integer `class`. +/// Two genomes with the same class have divergence 0.0; different classes give 1.0. +#[derive(Clone, Debug, PartialEq)] +struct Genome { + class: i32, + val: f32, +} + +impl Speciated for Genome { + type Context = (); + + fn divergence(&self, other: &Self, _: &()) -> f32 { + if self.class == other.class { + 0.0 + } else { + 1.0 + } + } +} + +impl GenerateRandom for Genome { + fn gen_random(rng: &mut impl rand::Rng) -> Self { + Self { + class: rng.random_range(0..3), + val: rng.random(), + } + } +} + +impl RandomlyMutable for Genome { + type Context = (); + + fn mutate(&mut self, _: &(), rate: f32, rng: &mut impl rand::Rng) { + self.val += rng.random::() * rate; + } +} + +impl Crossover for Genome { + type Context = (); + + fn crossover(&self, other: &Self, _: &(), _rate: f32, _rng: &mut impl rand::Rng) -> Self { + Self { + class: self.class, + val: (self.val + other.val) / 2.0, + } + } +} + +fn fitness(g: &Genome) -> f32 { + g.val +} + +// ───────────────────────────────────────────────────────────────────────────── +// SpeciatedPopulation — species grouping +// ───────────────────────────────────────────────────────────────────────────── + +/// All identical genomes must end up in a single species. +#[test] +fn identical_genomes_in_same_species() { + let genomes: Vec = (0..5) + .map(|_| Genome { + class: 0, + val: 0.0, + }) + .collect(); + let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); + assert_eq!(pop.species.len(), 1, "expected a single species"); + assert_eq!( + pop.species[0].len(), + 5, + "all genomes must belong to the single species" + ); +} + +/// Genomes of four distinct classes must form four separate species. +#[test] +fn different_class_genomes_in_different_species() { + let genomes: Vec = (0..4) + .map(|i| Genome { + class: i, + val: 0.0, + }) + .collect(); + let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); + assert_eq!(pop.species.len(), 4); +} + +/// With a threshold > 1.0 (larger than the max divergence) all genomes, +/// regardless of class, must end up in the same species. +#[test] +fn high_threshold_groups_all_genomes() { + let genomes: Vec = (0..6) + .map(|i| Genome { + class: i % 3, + val: 0.0, + }) + .collect(); + // Divergence is at most 1.0; with threshold 1.5 everything is "close enough". + let pop = SpeciatedPopulation::from_genomes(&genomes, 1.5, &()); + assert_eq!(pop.species.len(), 1, "all genomes must be in one species"); +} + +/// Every genome index must appear in exactly one species. +#[test] +fn every_genome_index_appears_exactly_once() { + let n = 9usize; + let genomes: Vec = (0..n) + .map(|i| Genome { + class: (i % 3) as i32, + val: i as f32, + }) + .collect(); + let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); + + let mut seen = vec![false; n]; + for species in &pop.species { + for &idx in species { + assert!(!seen[idx], "genome index {idx} appears in more than one species"); + seen[idx] = true; + } + } + assert!( + seen.iter().all(|&v| v), + "not every genome index appeared in a species" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SpeciatedPopulation — insert_genome +// ───────────────────────────────────────────────────────────────────────────── + +/// Inserting a genome from a new class must create a new species. +#[test] +fn insert_genome_creates_new_species_for_novel_genome() { + let genomes = vec![ + Genome { + class: 0, + val: 0.0, + }, + Genome { + class: 1, + val: 0.0, + }, // divergence 1.0 > threshold 0.5 → new species + ]; + let mut pop = SpeciatedPopulation { + species: vec![vec![0]], + threshold: 0.5, + }; + let created_new = pop.insert_genome(1, &genomes, &()); + assert!(created_new, "expected a new species to be created"); + assert_eq!(pop.species.len(), 2); +} + +/// Inserting a genome from an existing class must join that species. +#[test] +fn insert_genome_joins_existing_species_for_similar_genome() { + let genomes = vec![ + Genome { + class: 0, + val: 0.0, + }, + Genome { + class: 0, + val: 1.0, + }, // divergence 0.0 < threshold 0.5 → joins species + ]; + let mut pop = SpeciatedPopulation { + species: vec![vec![0]], + threshold: 0.5, + }; + let created_new = pop.insert_genome(1, &genomes, &()); + assert!(!created_new, "must not create a new species for a similar genome"); + assert_eq!(pop.species.len(), 1); + assert_eq!(pop.species[0].len(), 2); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SpeciatedPopulation — round-robin iterator +// ───────────────────────────────────────────────────────────────────────────── + +/// The round-robin iterator must visit every genome index within one full cycle. +/// With equal-sized species the cycle length equals the total number of genomes. +#[test] +fn round_robin_covers_all_genomes() { + // 3 classes × 2 genomes each = 6 genomes, 3 species of equal size. + let genomes: Vec = (0..6) + .map(|i| Genome { + class: (i % 3) as i32, + val: i as f32, + }) + .collect(); + let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); + + let mut seen = vec![false; 6]; + for idx in pop.round_robin().take(6) { + seen[idx] = true; + } + assert!( + seen.iter().all(|&v| v), + "round_robin must cover all genome indices in one cycle" + ); +} + +/// The enumerate variant must yield matching (species_index, genome_index) pairs. +#[test] +fn round_robin_enumerate_species_index_is_valid() { + let genomes: Vec = (0..6) + .map(|i| Genome { + class: (i % 3) as i32, + val: i as f32, + }) + .collect(); + let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); + + for (species_i, genome_i) in pop.round_robin_enumerate().take(12) { + assert!( + pop.species[species_i].contains(&genome_i), + "genome index {genome_i} is not in species {species_i}" + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SpeciatedFitnessEliminator — fitness scaling and population-size invariants +// ───────────────────────────────────────────────────────────────────────────── + +/// The population size must remain constant when using [`SpeciatedFitnessEliminator`] +/// together with [`SpeciatedCrossoverRepopulator`]. +#[test] +fn speciated_sim_population_size_preserved() { + let mut rng = rand::rng(); + let initial_size = 30; + + let eliminator = SpeciatedFitnessEliminator::new(fitness, 0.5, 0.5, (), ()); + let repopulator = SpeciatedCrossoverRepopulator::new( + 0.1, + 0.5, + ActionIfIsolated::CrossoverSimilarSpecies, + (), + (), + ); + + let mut sim = GeneticSim::new( + Vec::::gen_random(&mut rng, initial_size), + eliminator, + repopulator, + ); + sim.perform_generations(20); + assert_eq!(sim.genomes.len(), initial_size); +} + +/// Speciation must give fitness bonuses to rare/isolated genomes: +/// an isolated genome with *lower* raw fitness can out-compete a large species +/// with higher raw fitness because the large species has its fitness divided by +/// its size. +/// +/// Setup: +/// - 4 genomes of class 0 with val = 1.0 → adjusted fitness = 1.0 / 4 = 0.25 +/// - 1 genome of class 1 with val = 0.5 → adjusted fitness = 0.5 / 1 = 0.5 +/// +/// With threshold = 0.5 and 5 genomes, 3 survive. +/// Sorted adjusted fitness: [0.5, 0.25, 0.25, 0.25, 0.25] +/// → the class-1 genome (raw 0.5) must survive despite the lower raw fitness. +#[test] +fn speciation_protects_rare_species() { + let mut class0_genomes: Vec = (0..4) + .map(|_| Genome { + class: 0, + val: 1.0, + }) + .collect(); + let rare = Genome { + class: 1, + val: 0.5, + }; + class0_genomes.push(rare.clone()); + + let mut eliminator = SpeciatedFitnessEliminator::new(fitness, 0.5, 0.5, (), ()); + let survivors = eliminator.eliminate(class0_genomes); + + assert!( + survivors.iter().any(|g| g == &rare), + "the rare species genome must survive despite lower raw fitness" + ); +} From 4af20c58f1e8a43f7b01b8a60b7929f51cc7f875 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:30:58 +0000 Subject: [PATCH 26/38] cargo fmt --- genetic-rs/tests/repopulator.rs | 8 +++-- genetic-rs/tests/simulation.rs | 15 ++++++--- genetic-rs/tests/speciation.rs | 56 ++++++++++----------------------- 3 files changed, 32 insertions(+), 47 deletions(-) diff --git a/genetic-rs/tests/repopulator.rs b/genetic-rs/tests/repopulator.rs index 5ac2462..27235fd 100644 --- a/genetic-rs/tests/repopulator.rs +++ b/genetic-rs/tests/repopulator.rs @@ -76,7 +76,10 @@ fn mitosis_zero_mutation_rate_produces_clone() { let parent = Genome(42.0); let mut rng = rand::rng(); let child = parent.divide(&(), 0.0, &mut rng); - assert_eq!(child, parent, "child at rate 0.0 must be identical to parent"); + assert_eq!( + child, parent, + "child at rate 0.0 must be identical to parent" + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -147,8 +150,7 @@ fn from_parent_includes_original_parent() { let parent = Genome(7.0); let population = Vec::::from_parent(parent.clone(), 5, (), 0.0); assert_eq!( - population[0], - parent, + population[0], parent, "the first element must be the original parent" ); } diff --git a/genetic-rs/tests/simulation.rs b/genetic-rs/tests/simulation.rs index 01c4005..12ed2c6 100644 --- a/genetic-rs/tests/simulation.rs +++ b/genetic-rs/tests/simulation.rs @@ -104,7 +104,11 @@ fn fitness_eliminator_keeps_top_by_fitness() { let survivors = eliminator.eliminate(genomes); // threshold=0.5, n=10 → floor(10*0.5) + 1 = 6 survivors. - assert_eq!(survivors.len(), 6, "expected 6 survivors with default threshold"); + assert_eq!( + survivors.len(), + 6, + "expected 6 survivors with default threshold" + ); // Every survivor must be in the top half (fitness ≥ 4.0). for g in &survivors { @@ -165,7 +169,11 @@ fn fitness_eliminator_custom_threshold() { let genomes: Vec = (0..10).map(|i| Genome(i as f32)).collect(); let mut eliminator = FitnessEliminator::new(fitness, 0.3, ()); let survivors = eliminator.eliminate(genomes); - assert_eq!(survivors.len(), 4, "expected 4 survivors with threshold=0.3"); + assert_eq!( + survivors.len(), + 4, + "expected 4 survivors with threshold=0.3" + ); } /// [`FitnessEliminator::new`] must panic when the threshold is outside [0, 1]. @@ -219,8 +227,7 @@ fn observer_called_once_per_generation() { sim.perform_generations(7); assert_eq!( - sim.eliminator.observer.0, - 7, + sim.eliminator.observer.0, 7, "observer must be called once per generation" ); } diff --git a/genetic-rs/tests/speciation.rs b/genetic-rs/tests/speciation.rs index db28e1a..448ccf4 100644 --- a/genetic-rs/tests/speciation.rs +++ b/genetic-rs/tests/speciation.rs @@ -67,12 +67,7 @@ fn fitness(g: &Genome) -> f32 { /// All identical genomes must end up in a single species. #[test] fn identical_genomes_in_same_species() { - let genomes: Vec = (0..5) - .map(|_| Genome { - class: 0, - val: 0.0, - }) - .collect(); + let genomes: Vec = (0..5).map(|_| Genome { class: 0, val: 0.0 }).collect(); let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); assert_eq!(pop.species.len(), 1, "expected a single species"); assert_eq!( @@ -85,12 +80,7 @@ fn identical_genomes_in_same_species() { /// Genomes of four distinct classes must form four separate species. #[test] fn different_class_genomes_in_different_species() { - let genomes: Vec = (0..4) - .map(|i| Genome { - class: i, - val: 0.0, - }) - .collect(); + let genomes: Vec = (0..4).map(|i| Genome { class: i, val: 0.0 }).collect(); let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); assert_eq!(pop.species.len(), 4); } @@ -125,7 +115,10 @@ fn every_genome_index_appears_exactly_once() { let mut seen = vec![false; n]; for species in &pop.species { for &idx in species { - assert!(!seen[idx], "genome index {idx} appears in more than one species"); + assert!( + !seen[idx], + "genome index {idx} appears in more than one species" + ); seen[idx] = true; } } @@ -143,14 +136,8 @@ fn every_genome_index_appears_exactly_once() { #[test] fn insert_genome_creates_new_species_for_novel_genome() { let genomes = vec![ - Genome { - class: 0, - val: 0.0, - }, - Genome { - class: 1, - val: 0.0, - }, // divergence 1.0 > threshold 0.5 → new species + Genome { class: 0, val: 0.0 }, + Genome { class: 1, val: 0.0 }, // divergence 1.0 > threshold 0.5 → new species ]; let mut pop = SpeciatedPopulation { species: vec![vec![0]], @@ -165,21 +152,18 @@ fn insert_genome_creates_new_species_for_novel_genome() { #[test] fn insert_genome_joins_existing_species_for_similar_genome() { let genomes = vec![ - Genome { - class: 0, - val: 0.0, - }, - Genome { - class: 0, - val: 1.0, - }, // divergence 0.0 < threshold 0.5 → joins species + Genome { class: 0, val: 0.0 }, + Genome { class: 0, val: 1.0 }, // divergence 0.0 < threshold 0.5 → joins species ]; let mut pop = SpeciatedPopulation { species: vec![vec![0]], threshold: 0.5, }; let created_new = pop.insert_genome(1, &genomes, &()); - assert!(!created_new, "must not create a new species for a similar genome"); + assert!( + !created_new, + "must not create a new species for a similar genome" + ); assert_eq!(pop.species.len(), 1); assert_eq!(pop.species[0].len(), 2); } @@ -273,16 +257,8 @@ fn speciated_sim_population_size_preserved() { /// → the class-1 genome (raw 0.5) must survive despite the lower raw fitness. #[test] fn speciation_protects_rare_species() { - let mut class0_genomes: Vec = (0..4) - .map(|_| Genome { - class: 0, - val: 1.0, - }) - .collect(); - let rare = Genome { - class: 1, - val: 0.5, - }; + let mut class0_genomes: Vec = (0..4).map(|_| Genome { class: 0, val: 1.0 }).collect(); + let rare = Genome { class: 1, val: 0.5 }; class0_genomes.push(rare.clone()); let mut eliminator = SpeciatedFitnessEliminator::new(fitness, 0.5, 0.5, (), ()); From 14aa772cc09c353daae5d8b0d32b36af207c92c8 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:40:23 +0000 Subject: [PATCH 27/38] add a tip for genetic-rs-extras in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 89644b8..f98c360 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ First off, this crate comes with the `builtin`, `genrand`, `crossover`, `knockou > [!NOTE] > If you are interested in implementing NEAT with this, or just want a more complex example, check out the [neat](https://crates.io/crates/neat) crate +> [!TIP] +> For quality of life improvements and utility features, consider using [genetic-rs-extras](https://crates.io/crates/genetic-rs-extras). + Here's a simple genetic algorithm: ```rust From 38de4f14b4f565ddc5038be735225f186c8fc7c5 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:48:21 +0000 Subject: [PATCH 28/38] rewrite crate promotion as ecosystem section --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f98c360..2be8055 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,14 @@ A small framework for managing genetic algorithms. ### Features First off, this crate comes with the `builtin`, `genrand`, `crossover`, `knockout`, and `speciation` features by default. If you want the simulation to be parallelized (which is most usecases), add the `rayon` feature. There are also some convenient macros with the `derive` feature. -### How to Use -> [!NOTE] -> If you are interested in implementing NEAT with this, or just want a more complex example, check out the [neat](https://crates.io/crates/neat) crate +### Ecosystem +This framework was created with a high degree of modularity in mind, allowing other crates to contribute to the ecosystem. Here's a list of some good crates: +- [neat](https://crates.io/crates/neat) - Handles complex reproduction and prediction logic for the NEAT algorithm, allowing you to create AI simulations with ease. It also functions as a pretty good example for the more complex usecases of the crate's traits. +- [genetic-rs-extras](https://crates.io/crates/genetic-rs-extras) - A companion crate with quality-of-life improvements and utility features. -> [!TIP] -> For quality of life improvements and utility features, consider using [genetic-rs-extras](https://crates.io/crates/genetic-rs-extras). +If you have a `genetic-rs`-based crate and you'd like it to be featured here, submit a PR or discussion post and I'll consider it. +### How to Use Here's a simple genetic algorithm: ```rust From c4641861ea68fce77d8369ed9fbbef2d791897ff Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:49:05 +0000 Subject: [PATCH 29/38] fix comment about example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2be8055..2233760 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ fn main() { } ``` -That is the minimal code for a working genetic algorithm on default features. You can [read the docs](https://docs.rs/genetic-rs) or [check the examples](/genetic-rs/examples/) for more complicated systems. I highly recommend looking into crossover reproduction, as it tends to produce better results than mitosis. +That is the minimal code for a working genetic algorithm with just the `derive` feature. You can [read the docs](https://docs.rs/genetic-rs) or [check the examples](/genetic-rs/examples/) for more complicated systems. I highly recommend looking into crossover reproduction, as it tends to produce better results than mitosis. ### License This project falls under the `MIT` license. From 739bd39ae2c039796aec49ecd27863df6525feb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:00:03 +0000 Subject: [PATCH 30/38] Initial plan From be23d934993535d1223170ed7bb46c05dd4db489 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:01:34 +0000 Subject: [PATCH 31/38] Initial plan From 4cff2ff5e34a1cba88ad99146406802b60f45f23 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:02:24 +0000 Subject: [PATCH 32/38] add an explicit panic for stupid counts in from_parent --- genetic-rs-common/src/builtin/repopulator.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index f4c3c88..1c8366e 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -96,6 +96,8 @@ pub trait FromParent { impl FromParent for Vec { fn from_parent(parent: G, count: usize, ctx: G::Context, rate: f32) -> Self { + assert!(count > 0, "Count must be greater than 0"); + let mut repopulator = MitosisRepopulator::new(rate, ctx); let mut genomes = vec![parent]; repopulator.repopulate(&mut genomes, count); From c39fdc04174f12d0a5beeeb024eab02f00fca7d5 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:04:57 +0000 Subject: [PATCH 33/38] small change to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2233760..cad7566 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ fn main() { } ``` -That is the minimal code for a working genetic algorithm with just the `derive` feature. You can [read the docs](https://docs.rs/genetic-rs) or [check the examples](/genetic-rs/examples/) for more complicated systems. I highly recommend looking into crossover reproduction, as it tends to produce better results than mitosis. +That is the minimal code for a working genetic algorithm with just the `derive` feature (+ defaults). You can [read the docs](https://docs.rs/genetic-rs) or [check the examples](/genetic-rs/examples/) for more complicated systems. I highly recommend looking into crossover reproduction, as it tends to produce better results than mitosis. ### License This project falls under the `MIT` license. From 8e3e0859a8188248cbca245569938689fd635e1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:05:12 +0000 Subject: [PATCH 34/38] Change FitnessObserver blanket impl bound from Fn to FnMut Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs-common/src/builtin/eliminator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 5309d4e..c47425a 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -54,7 +54,7 @@ impl FitnessObserver for () { impl FitnessObserver for F where - F: Fn(&[(G, f32)]), + F: FnMut(&[(G, f32)]), G: FeatureBoundedGenome, { fn observe(&mut self, fitnesses: &[(G, f32)]) { From 2cc81853340ed73a4d9f536fc583932c31b57737 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:07:09 +0000 Subject: [PATCH 35/38] Restrict SpeciatedPopulation.species to private with accessor to enforce invariants Co-authored-by: HyperCodec <72839119+HyperCodec@users.noreply.github.com> --- genetic-rs-common/src/builtin/eliminator.rs | 8 +++--- genetic-rs-common/src/builtin/repopulator.rs | 8 +++--- genetic-rs-common/src/speciation.rs | 24 ++++++++++++---- genetic-rs/tests/speciation.rs | 30 +++++++++----------- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index 5309d4e..b2a60bc 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -654,10 +654,10 @@ mod speciation { SpeciatedPopulation::from_genomes(&genomes, self.speciation_threshold, &self.ctx); let mut fitnesses = vec![0.0; genomes.len()]; - for species in population.species { + for species in population.species() { let len = species.len() as f32; debug_assert!(len != 0.0); - for index in species { + for &index in species { let genome = &genomes[index]; let fitness = self.inner.fitness_fn.fitness(genome); if fitness < 0.0 { @@ -682,10 +682,10 @@ mod speciation { let mut fitnesses = vec![0.0; genomes.len()]; - for species in population.species { + for species in population.species() { let len = species.len() as f32; debug_assert!(len != 0.0); - for index in species { + for &index in species { let genome = &genomes[index]; let fitness = self.inner.fitness_fn.fitness(genome); if fitness < 0.0 { diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index f4c3c88..67c31c3 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -283,7 +283,7 @@ mod speciation { // if all species are isolated, we fall back to the inner crossover repopulator to avoid an infinite loop. if matches!(self.action_if_isolated, ActionIfIsolated::DoNothing) - && !population.species.iter().any(|s| s.len() >= 2) + && !population.species().iter().any(|s| s.len() >= 2) { self.inner.repopulate(genomes, target_size); return; @@ -295,7 +295,7 @@ mod speciation { let mut i = 0; while i < amount_to_make { let (species_i, genome_i) = species_cycle.next().unwrap(); - let species = &population.species[species_i]; + let species = &population.species()[species_i]; let parent1 = &genomes[genome_i]; if species.len() < 2 { match self.action_if_isolated { @@ -314,7 +314,7 @@ mod speciation { ActionIfIsolated::CrossoverSimilarSpecies => { let mut best_species_i = 0; let mut best_divergence = f32::MAX; - for (j, species) in population.species.iter().enumerate() { + for (j, species) in population.species().iter().enumerate() { if j == species_i || species.is_empty() { continue; } @@ -326,7 +326,7 @@ mod speciation { } } - let best_species = &population.species[best_species_i]; + let best_species = &population.species()[best_species_i]; let j = rng.random_range(0..best_species.len()); let parent2 = &genomes[best_species[j]]; let child = parent1.crossover( diff --git a/genetic-rs-common/src/speciation.rs b/genetic-rs-common/src/speciation.rs index 6dd8fcb..402ab18 100644 --- a/genetic-rs-common/src/speciation.rs +++ b/genetic-rs-common/src/speciation.rs @@ -19,8 +19,9 @@ pub trait Speciated { pub struct SpeciatedPopulation { /// The species in this population. Each species is a vector of indices into the original genome vector. /// The first genome in a species is its representation (i.e. the one that gets compared to other genomes to determine - /// if they belong in the species) - pub species: Vec>, + /// if they belong in the species). + /// Invariant: every inner `Vec` is non-empty. + species: Vec>, /// The threshold used to determine if a genome belongs in a species. If the divergence between a genome and the representative genome /// of a species is less than this threshold, then the genome belongs in that species. @@ -28,6 +29,20 @@ pub struct SpeciatedPopulation { } impl SpeciatedPopulation { + /// Creates a new, empty [`SpeciatedPopulation`] with the given threshold. + pub fn new(threshold: f32) -> Self { + Self { + species: Vec::new(), + threshold, + } + } + + /// Returns the species in this population. + /// Each inner slice is guaranteed to be non-empty. + pub fn species(&self) -> &[Vec] { + &self.species + } + /// Inserts a genome into the speciated population. /// Returns whether a new species was created by this insertion. pub fn insert_genome( @@ -48,10 +63,7 @@ impl SpeciatedPopulation { /// Note that this can be O(n^2) worst case, but is typically much faster in practice, /// especially if the genome structure doesn't mutate often. pub fn from_genomes(population: &[G], threshold: f32, ctx: &G::Context) -> Self { - let mut speciated_population = SpeciatedPopulation { - species: Vec::new(), - threshold, - }; + let mut speciated_population = SpeciatedPopulation::new(threshold); for index in 0..population.len() { speciated_population.insert_genome(index, population, ctx); } diff --git a/genetic-rs/tests/speciation.rs b/genetic-rs/tests/speciation.rs index 448ccf4..751a7ca 100644 --- a/genetic-rs/tests/speciation.rs +++ b/genetic-rs/tests/speciation.rs @@ -69,9 +69,9 @@ fn fitness(g: &Genome) -> f32 { fn identical_genomes_in_same_species() { let genomes: Vec = (0..5).map(|_| Genome { class: 0, val: 0.0 }).collect(); let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); - assert_eq!(pop.species.len(), 1, "expected a single species"); + assert_eq!(pop.species().len(), 1, "expected a single species"); assert_eq!( - pop.species[0].len(), + pop.species()[0].len(), 5, "all genomes must belong to the single species" ); @@ -82,7 +82,7 @@ fn identical_genomes_in_same_species() { fn different_class_genomes_in_different_species() { let genomes: Vec = (0..4).map(|i| Genome { class: i, val: 0.0 }).collect(); let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); - assert_eq!(pop.species.len(), 4); + assert_eq!(pop.species().len(), 4); } /// With a threshold > 1.0 (larger than the max divergence) all genomes, @@ -97,7 +97,7 @@ fn high_threshold_groups_all_genomes() { .collect(); // Divergence is at most 1.0; with threshold 1.5 everything is "close enough". let pop = SpeciatedPopulation::from_genomes(&genomes, 1.5, &()); - assert_eq!(pop.species.len(), 1, "all genomes must be in one species"); + assert_eq!(pop.species().len(), 1, "all genomes must be in one species"); } /// Every genome index must appear in exactly one species. @@ -113,7 +113,7 @@ fn every_genome_index_appears_exactly_once() { let pop = SpeciatedPopulation::from_genomes(&genomes, 0.5, &()); let mut seen = vec![false; n]; - for species in &pop.species { + for species in pop.species() { for &idx in species { assert!( !seen[idx], @@ -139,13 +139,11 @@ fn insert_genome_creates_new_species_for_novel_genome() { Genome { class: 0, val: 0.0 }, Genome { class: 1, val: 0.0 }, // divergence 1.0 > threshold 0.5 → new species ]; - let mut pop = SpeciatedPopulation { - species: vec![vec![0]], - threshold: 0.5, - }; + let mut pop = SpeciatedPopulation::new(0.5); + pop.insert_genome(0, &genomes, &()); let created_new = pop.insert_genome(1, &genomes, &()); assert!(created_new, "expected a new species to be created"); - assert_eq!(pop.species.len(), 2); + assert_eq!(pop.species().len(), 2); } /// Inserting a genome from an existing class must join that species. @@ -155,17 +153,15 @@ fn insert_genome_joins_existing_species_for_similar_genome() { Genome { class: 0, val: 0.0 }, Genome { class: 0, val: 1.0 }, // divergence 0.0 < threshold 0.5 → joins species ]; - let mut pop = SpeciatedPopulation { - species: vec![vec![0]], - threshold: 0.5, - }; + let mut pop = SpeciatedPopulation::new(0.5); + pop.insert_genome(0, &genomes, &()); let created_new = pop.insert_genome(1, &genomes, &()); assert!( !created_new, "must not create a new species for a similar genome" ); - assert_eq!(pop.species.len(), 1); - assert_eq!(pop.species[0].len(), 2); + assert_eq!(pop.species().len(), 1); + assert_eq!(pop.species()[0].len(), 2); } // ───────────────────────────────────────────────────────────────────────────── @@ -208,7 +204,7 @@ fn round_robin_enumerate_species_index_is_valid() { for (species_i, genome_i) in pop.round_robin_enumerate().take(12) { assert!( - pop.species[species_i].contains(&genome_i), + pop.species()[species_i].contains(&genome_i), "genome index {genome_i} is not in species {species_i}" ); } From 09dcefe69da6f7a08b0b94025a82199020281f44 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:33:49 +0000 Subject: [PATCH 36/38] fix edge case in SpeciatedCrossoverRepopulator --- genetic-rs-common/src/builtin/repopulator.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index 96994d8..a8337e4 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -284,8 +284,9 @@ mod speciation { SpeciatedPopulation::from_genomes(genomes, self.speciation_threshold, &self.ctx); // if all species are isolated, we fall back to the inner crossover repopulator to avoid an infinite loop. - if matches!(self.action_if_isolated, ActionIfIsolated::DoNothing) - && !population.species().iter().any(|s| s.len() >= 2) + if (matches!(self.action_if_isolated, ActionIfIsolated::DoNothing) + && !population.species().iter().any(|s| s.len() >= 2)) || + (matches!(self.action_if_isolated, ActionIfIsolated::CrossoverRandom) && initial_size == 1) { self.inner.repopulate(genomes, target_size); return; From 2bd9439397696e757da509045269edd59aeed300 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:33:57 +0000 Subject: [PATCH 37/38] cargo fmt --- genetic-rs-common/src/builtin/repopulator.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/genetic-rs-common/src/builtin/repopulator.rs b/genetic-rs-common/src/builtin/repopulator.rs index a8337e4..0232698 100644 --- a/genetic-rs-common/src/builtin/repopulator.rs +++ b/genetic-rs-common/src/builtin/repopulator.rs @@ -285,8 +285,9 @@ mod speciation { // if all species are isolated, we fall back to the inner crossover repopulator to avoid an infinite loop. if (matches!(self.action_if_isolated, ActionIfIsolated::DoNothing) - && !population.species().iter().any(|s| s.len() >= 2)) || - (matches!(self.action_if_isolated, ActionIfIsolated::CrossoverRandom) && initial_size == 1) + && !population.species().iter().any(|s| s.len() >= 2)) + || (matches!(self.action_if_isolated, ActionIfIsolated::CrossoverRandom) + && initial_size == 1) { self.inner.repopulate(genomes, target_size); return; From cb71d17e0a7bbf14a1a1bc3609311859f6b7c7d6 Mon Sep 17 00:00:00 2001 From: Tristan Murphy <72839119+HyperCodec@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:50:19 +0000 Subject: [PATCH 38/38] bump version number --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++++- genetic-rs-common/src/builtin/eliminator.rs | 4 +--- genetic-rs-common/src/lib.rs | 21 +++++++++++---------- genetic-rs-macros/Cargo.toml | 2 +- genetic-rs/Cargo.toml | 4 ++-- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7904c9e..99c14bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "genetic-rs" -version = "1.3.0" +version = "1.4.0" dependencies = [ "genetic-rs-common", "genetic-rs-macros", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "genetic-rs-common" -version = "1.3.0" +version = "1.4.0" dependencies = [ "itertools", "rand", @@ -137,7 +137,7 @@ dependencies = [ [[package]] name = "genetic-rs-macros" -version = "1.3.0" +version = "1.4.0" dependencies = [ "darling", "genetic-rs-common", diff --git a/Cargo.toml b/Cargo.toml index d350add..972d04e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,13 @@ members = ["genetic-rs", "genetic-rs-common", "genetic-rs-macros"] resolver = "2" [workspace.package] -version = "1.3.0" +version = "1.4.0" authors = ["HyperCodec"] homepage = "https://github.com/hypercodec/genetic-rs" repository = "https://github.com/hypercodec/genetic-rs" license = "MIT" edition = "2021" + +[workspace.dependencies] +genetic-rs-common = { path = "genetic-rs-common", version = "1.4.0", default-features = false } +genetic-rs-macros = { path = "genetic-rs-macros", version = "1.4.0" } diff --git a/genetic-rs-common/src/builtin/eliminator.rs b/genetic-rs-common/src/builtin/eliminator.rs index f41c02f..9130359 100644 --- a/genetic-rs-common/src/builtin/eliminator.rs +++ b/genetic-rs-common/src/builtin/eliminator.rs @@ -1,5 +1,3 @@ -use std::ops::Not; - use crate::Eliminator; use crate::FeatureBoundedGenome; @@ -390,7 +388,7 @@ mod knockout { } } - impl Not for KnockoutWinner { + impl std::ops::Not for KnockoutWinner { type Output = Self; fn not(self) -> Self::Output { diff --git a/genetic-rs-common/src/lib.rs b/genetic-rs-common/src/lib.rs index f77903d..39e545c 100644 --- a/genetic-rs-common/src/lib.rs +++ b/genetic-rs-common/src/lib.rs @@ -130,16 +130,7 @@ where fn gen_random(rng: &mut impl rand::Rng, amount: usize) -> Self; } -/// Rayon version of the [`GenerateRandomCollection`] trait -#[cfg(all(feature = "genrand", feature = "rayon"))] -pub trait GenerateRandomCollectionParallel -where - T: GenerateRandom + Send, -{ - /// Generate a random collection of the inner objects with the given amount. Does not pass in rng like the sync counterpart. - fn par_gen_random(amount: usize) -> Self; -} - +#[cfg(feature = "genrand")] impl GenerateRandomCollection for C where C: FromIterator, @@ -150,6 +141,16 @@ where } } +/// Rayon version of the [`GenerateRandomCollection`] trait +#[cfg(all(feature = "genrand", feature = "rayon"))] +pub trait GenerateRandomCollectionParallel +where + T: GenerateRandom + Send, +{ + /// Generate a random collection of the inner objects with the given amount. Does not pass in rng like the sync counterpart. + fn par_gen_random(amount: usize) -> Self; +} + #[cfg(feature = "rayon")] impl GenerateRandomCollectionParallel for C where diff --git a/genetic-rs-macros/Cargo.toml b/genetic-rs-macros/Cargo.toml index 24d55b9..7f9aacc 100644 --- a/genetic-rs-macros/Cargo.toml +++ b/genetic-rs-macros/Cargo.toml @@ -28,7 +28,7 @@ unexpected_cfgs = { level = "allow", check-cfg = ["cfg(docsrs)"] } [dependencies] darling = "0.23.0" -genetic-rs-common = { path = "../genetic-rs-common", version = "1.3.0" } +genetic-rs-common.workspace = true proc-macro2 = "1.0.106" quote = "1.0.44" syn = "2.0.114" diff --git a/genetic-rs/Cargo.toml b/genetic-rs/Cargo.toml index 58d03c7..97fa327 100644 --- a/genetic-rs/Cargo.toml +++ b/genetic-rs/Cargo.toml @@ -22,8 +22,8 @@ rayon = ["genetic-rs-common/rayon"] derive = ["dep:genetic-rs-macros", "builtin"] [dependencies] -genetic-rs-common = { path = "../genetic-rs-common", version = "1.3.0", default-features = false } -genetic-rs-macros = { path = "../genetic-rs-macros", version = "1.3.0", optional = true } +genetic-rs-common.workspace = true +genetic-rs-macros = { workspace = true, optional = true } [package.metadata.docs.rs] all-features = true