Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
345 changes: 311 additions & 34 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["genetic-rs", "genetic-rs-common", "genetic-rs-macros"]
resolver = "2"

[workspace.package]
version = "1.1.0"
version = "1.2.0"
authors = ["HyperCodec"]
homepage = "https://github.com/hypercodec/genetic-rs"
repository = "https://github.com/hypercodec/genetic-rs"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use genetic_rs::prelude::*;

// `Mitosis` can be derived if both `Clone` and `RandomlyMutable` are present.
#[derive(Clone, Debug, Mitosis)]
#[mitosis(use_randmut = true)]
struct MyGenome {
field1: f32,
}
Expand Down Expand Up @@ -53,7 +54,7 @@ fn main() {
// size will be preserved in builtin repopulators, but it is not required to keep a constant size if you were to build your own.
// in this case, the compiler can infer the type of `Vec::gen_random` because of the input of `my_fitness_fn`.
Vec::gen_random(&mut rng, 100),
FitnessEliminator::new_with_default(my_fitness_fn),
FitnessEliminator::new_without_observer(my_fitness_fn),
MitosisRepopulator::new(0.25, ()), // 25% mutation rate, empty context
);

Expand Down
2 changes: 1 addition & 1 deletion genetic-rs-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ rustdoc-args = ["--cfg", "docsrs"]

[dependencies]
itertools = { version = "0.14.0", optional = true }
rand = { version = "0.9.2", optional = true }
rand = { version = "0.10.0", optional = true }
rayon = { version = "1.11.0", optional = true }
198 changes: 192 additions & 6 deletions genetic-rs-common/src/builtin/eliminator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,38 +34,79 @@ pub trait FeatureBoundedFitnessFn<G: FeatureBoundedGenome>: FitnessFn<G> + Send
#[cfg(feature = "rayon")]
impl<G: FeatureBoundedGenome, T: FitnessFn<G> + Send + Sync> FeatureBoundedFitnessFn<G> for T {}

/// A trait for observing fitness scores. This can be used to implement things like logging or statistics collection.
pub trait FitnessObserver<G> {
/// Observes the fitness scores of a generation of genomes.
fn observe(&self, fitnesses: &[(G, f32)]);
}

impl<G> FitnessObserver<G> for () {
fn observe(&self, _fitnesses: &[(G, f32)]) {}
}

#[cfg(not(feature = "rayon"))]
#[doc(hidden)]
pub trait FeatureBoundedFitnessObserver<G: FeatureBoundedGenome>: FitnessObserver<G> {}

#[cfg(not(feature = "rayon"))]
impl<G: FeatureBoundedGenome, T: FitnessObserver<G>> FeatureBoundedFitnessObserver<G> for T {}

#[cfg(feature = "rayon")]
#[doc(hidden)]
pub trait FeatureBoundedFitnessObserver<G: FeatureBoundedGenome>:
FitnessObserver<G> + Send + Sync
{
}
#[cfg(feature = "rayon")]
impl<G: FeatureBoundedGenome, T: FitnessObserver<G> + Send + Sync> FeatureBoundedFitnessObserver<G>
for T
{
}

/// A fitness-based eliminator that eliminates genomes based on their fitness scores.
pub struct FitnessEliminator<F: FitnessFn<G>, G: FeatureBoundedGenome> {
pub struct FitnessEliminator<
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G> = (),
> {
/// 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 threshold: f32,

/// The fitness observer used to observe fitness scores.
pub observer: O,

_marker: std::marker::PhantomData<G>,
}

impl<F, G> FitnessEliminator<F, G>
impl<F, G, O> FitnessEliminator<F, G, O>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G>,
{
/// The default threshold for the [`FitnessEliminator`]. This is the percentage of genomes to keep. All genomes below the median fitness will be eliminated.
pub const DEFAULT_THRESHOLD: f32 = 0.5;

/// Creates a new [`FitnessEliminator`] with a given fitness function and threshold.
/// Panics if the threshold is not between 0.0 and 1.0.
pub fn new(fitness_fn: F, threshold: f32) -> Self {
pub fn new(fitness_fn: F, threshold: f32, observer: O) -> Self {
if !(0.0..=1.0).contains(&threshold) {
panic!("Threshold must be between 0.0 and 1.0");
}
Self {
fitness_fn,
threshold,
observer,
_marker: std::marker::PhantomData,
}
}

/// Creates a new [`FitnessEliminator`] with a default threshold of 0.5 (all genomes below median fitness are eliminated).
pub fn new_with_default(fitness_fn: F) -> Self {
Self::new(fitness_fn, 0.5)
pub fn new_with_default_threshold(fitness_fn: F, observer: O) -> Self {
Self::new(fitness_fn, Self::DEFAULT_THRESHOLD, observer)
}

/// Calculates the fitness of each genome and sorts them by fitness.
Expand Down Expand Up @@ -97,18 +138,56 @@ where
fitnesses.sort_by(|(_a, afit), (_b, bfit)| bfit.partial_cmp(afit).unwrap());
fitnesses
}

/// Creates a new builder for [`FitnessEliminator`] to make it easier to construct with default parameters.
pub fn builder() -> FitnessEliminatorBuilder<F, G, O> {
FitnessEliminatorBuilder::default()
}
}

impl<F, G, O> FitnessEliminator<F, G, O>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G> + Default,
{
/// Creates a new [`FitnessEliminator`] with a default observer that does nothing.
pub fn new_with_default_observer(fitness_fn: F, threshold: f32) -> Self {
Self::new(fitness_fn, threshold, O::default())
}

/// Creates a new [`FitnessEliminator`] with a default threshold of 0.5 and a default observer.
/// You must specify the observer type explicitly, e.g., `FitnessEliminator::new_with_default::<()>(fitness_fn)`.
pub fn new_with_default(fitness_fn: F) -> Self {
Self::new_with_default_observer(fitness_fn, Self::DEFAULT_THRESHOLD)
}
}

impl<F, G> Eliminator<G> for FitnessEliminator<F, G>
/// Implementation specifically for the unit type `()` observer (the default).
impl<F, G> FitnessEliminator<F, G, ()>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
{
/// Creates a new [`FitnessEliminator`] with a default threshold of 0.5 and unit observer `()`.
/// This is a convenience function that doesn't require explicit type annotations.
pub fn new_without_observer(fitness_fn: F) -> Self {
Self::new(fitness_fn, Self::DEFAULT_THRESHOLD, ())
}
}

impl<F, G, O> Eliminator<G> for FitnessEliminator<F, G, O>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G>,
{
#[cfg(not(feature = "rayon"))]
fn eliminate(&self, genomes: Vec<G>) -> Vec<G> {
let mut fitnesses = self.calculate_and_sort(genomes);
let median_index = (fitnesses.len() as f32) * self.threshold;
fitnesses.truncate(median_index as usize + 1);
self.observer.observe(&fitnesses);
fitnesses.into_iter().map(|(g, _)| g).collect()
}

Expand All @@ -117,14 +196,92 @@ where
let mut fitnesses = self.calculate_and_sort(genomes);
let median_index = (fitnesses.len() as f32) * self.threshold;
fitnesses.truncate(median_index as usize + 1);
self.observer.observe(&fitnesses);
fitnesses.into_par_iter().map(|(g, _)| g).collect()
}
}

/// A builder for [`FitnessEliminator`] to make it easier to construct with default parameters.
pub struct FitnessEliminatorBuilder<F: FitnessFn<G>, G, O: FitnessObserver<G> = ()> {
fitness_fn: Option<F>,
threshold: f32,
observer: Option<O>,
_marker: std::marker::PhantomData<G>,
}

impl<F, G, O> FitnessEliminatorBuilder<F, G, O>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G>,
{
/// Sets the fitness function for the [`FitnessEliminator`].
pub fn fitness_fn(mut self, fitness_fn: F) -> Self {
self.fitness_fn = Some(fitness_fn);
self
}

/// Sets the threshold for the [`FitnessEliminator`].
pub fn threshold(mut self, threshold: f32) -> Self {
self.threshold = threshold;
self
}

/// Sets the observer for the [`FitnessEliminator`].
pub fn observer(mut self, observer: O) -> Self {
self.observer = Some(observer);
self
}

/// Builds the [`FitnessEliminator`].
/// Panics if the fitness function or observer was not set.
pub fn build_or_panic(self) -> FitnessEliminator<F, G, O> {
let fitness_fn = self.fitness_fn.expect("Fitness function must be set");
let observer = self.observer.expect(
"Observer must be set. Use build_or_default() if the observer implements Default.",
);
FitnessEliminator::new(fitness_fn, self.threshold, observer)
}
}

impl<F, G, O> FitnessEliminatorBuilder<F, G, O>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G> + Default,
{
/// Builds the [`FitnessEliminator`].
/// If no observer was set, uses the default observer implementation.
/// This method is only available when the observer type implements [`Default`].
pub fn build(self) -> FitnessEliminator<F, G, O> {
let fitness_fn = self.fitness_fn.expect("Fitness function must be set");
let observer = self.observer.unwrap_or_default();
FitnessEliminator::new(fitness_fn, self.threshold, observer)
}
}

impl<F, G, O> Default for FitnessEliminatorBuilder<F, G, O>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
O: FeatureBoundedFitnessObserver<G>,
{
fn default() -> Self {
Self {
fitness_fn: None,
threshold: 0.5,
observer: None,
_marker: std::marker::PhantomData,
}
}
}

#[cfg(feature = "knockout")]
mod knockout {
use std::cmp::Ordering;

use rand::RngExt;

use super::*;

/// A distinct type to help clarify the result of a knockout function.
Expand Down Expand Up @@ -183,6 +340,35 @@ mod knockout {
}
}

/// A knockout function that uses a fitness function to determine the winner.
pub struct FitnessKnockoutFn<F: FitnessFn<G>, G: FeatureBoundedGenome> {
/// The fitness function used to evaluate the genomes.
pub fitness_fn: F,
_marker: std::marker::PhantomData<G>,
}

impl<F: FitnessFn<G>, G: FeatureBoundedGenome> FitnessKnockoutFn<F, G> {
/// Creates a new [`FitnessKnockoutFn`] with a given fitness function.
pub fn new(fitness_fn: F) -> Self {
Self {
fitness_fn,
_marker: std::marker::PhantomData,
}
}
}

impl<F, G> KnockoutFn<G> for FitnessKnockoutFn<F, G>
where
F: FeatureBoundedFitnessFn<G>,
G: FeatureBoundedGenome,
{
fn knockout(&self, a: &G, b: &G) -> KnockoutWinner {
let afit = self.fitness_fn.fitness(a);
let bfit = self.fitness_fn.fitness(b);
afit.total_cmp(&bfit).into()
}
}

#[doc(hidden)]
#[cfg(not(feature = "rayon"))]
pub trait FeatureBoundedKnockoutFn<G>: KnockoutFn<G> {}
Expand Down
33 changes: 31 additions & 2 deletions genetic-rs-common/src/builtin/repopulator.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::Repopulator;
use rand::Rng as RandRng;

/// Used in other traits to randomly mutate genomes a given amount
pub trait RandomlyMutable {
Expand All @@ -10,16 +9,42 @@ pub trait RandomlyMutable {
fn mutate(&mut self, ctx: &Self::Context, rate: f32, rng: &mut impl rand::Rng);
}

// TODO rayon version
impl<'a, T: RandomlyMutable + 'a, I: Iterator<Item = &'a mut T>> RandomlyMutable for I {
type Context = T::Context;

fn mutate(&mut self, ctx: &Self::Context, rate: f32, rng: &mut impl rand::Rng) {
self.for_each(|x| x.mutate(ctx, rate, rng));
}
}

/// Used in dividually-reproducing [`Repopulator`]s
pub trait Mitosis: Clone {
/// Simulation-wide context required for this mitosis implementation.
type Context;

/// Create a new child with mutation. Similar to [RandomlyMutable::mutate], but returns a new instance instead of modifying the original.
/// Create a new child with mutation. Similar to [`RandomlyMutable::mutate`], but returns a new instance instead of modifying the original.
fn divide(&self, ctx: &<Self as Mitosis>::Context, rate: f32, rng: &mut impl rand::Rng)
-> Self;
}

impl<T: Mitosis> Mitosis for Vec<T> {
type Context = T::Context;

fn divide(
&self,
ctx: &<Self as Mitosis>::Context,
rate: f32,
rng: &mut impl rand::Rng,
) -> Self {
let mut child = Vec::with_capacity(self.len());
for gene in self {
child.push(gene.divide(ctx, rate, rng));
}
child
}
}

/// Repopulator that uses division reproduction to create new genomes.
pub struct MitosisRepopulator<G: Mitosis> {
/// The mutation rate to use when mutating genomes. 0.0 - 1.0
Expand Down Expand Up @@ -61,6 +86,8 @@ where

#[cfg(feature = "crossover")]
mod crossover {
use rand::RngExt;

use super::*;

/// Used in crossover-reproducing [`Repopulator`]s
Expand Down Expand Up @@ -134,6 +161,8 @@ pub use crossover::*;
mod speciation {
use std::collections::HashMap;

use rand::RngExt;

use super::*;

/// Used in speciated crossover nextgens. Allows for genomes to avoid crossover with ones that are too different.
Expand Down
2 changes: 1 addition & 1 deletion genetic-rs-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ genrand = []

[dependencies]
darling = "0.23.0"
genetic-rs-common = { path = "../genetic-rs-common", version = "1.1.0" }
genetic-rs-common = { path = "../genetic-rs-common", version = "1.2.0" }
proc-macro2 = "1.0.106"
quote = "1.0.44"
syn = "2.0.114"
Loading