evolve 0.4.0

A generic, composable genetic algorithm framework for Rust
Documentation

evolve

Crates.io Documentation License

A generic, composable genetic algorithm framework for Rust.

Note: This library is in rapid development. While breaking changes will be avoided where possible, there is no guarantee of API stability until 1.0.

evolve provides the building blocks to assemble genetic algorithms from reusable, type-safe components. Operators are composed using combinators — chain them into pipelines, weight them probabilistically, or repeat them to fill a population — all with zero-cost abstractions.

Features

  • Fully generic over genome type, fitness type, RNG, and fitness comparator
  • Built-in operators for selection, crossover, and mutation
  • Composable combinators for structuring the flow of the algorithm
  • Maximize and Minimize fitness comparators out of the box
  • Closures work as fitness evaluators and comparators via blanket trait impls
  • Minimal dependencies: rand and vecpool (optional pooled for parallel execution)

Available Operators

Selection

  • Tournament — tournament selection with replacement
  • Elitism — preserve the best N individuals
  • RouletteWheel — fitness-proportionate selection
  • Rank — rank-based selection (avoids premature convergence)
  • Sus — stochastic universal sampling (low-variance fitness-proportionate)

Crossover

  • SinglePoint — single-point crossover
  • TwoPoint — two-point crossover
  • Uniform — per-gene random parent selection
  • Arithmetic — numeric blending for continuous genomes (f32/f64)

Mutation

  • RandomReset — replace a random gene with a new random value
  • Gaussian — add Gaussian noise (continuous genomes)
  • Creep — add a small random offset (discrete genomes)
  • Swap — swap two random genes (permutation problems)
  • Inversion — reverse a random segment (permutation problems)
  • Scramble — shuffle a random segment (permutation problems)
  • SegmentDuplication — duplicate a random segment (variable-length)
  • SegmentDeletion — delete a random segment (variable-length)

Combinators

  • Pipeline — chain operators sequentially
  • Fill — repeat an operator until target population size
  • Combine — run operators on same input, merge outputs
  • Weighted — probabilistically select one operator per call
  • Repeat — apply an operator N times
  • Proportional — split output proportionally across operators
  • Conditional — apply operator A or B based on a predicate

Utility

  • Identity — no-op pass-through
  • WithRate — apply an operator per-individual with a given probability

Quick Start

use evolve::{
    algorithm::EvolutionaryAlgorithm,
    fitness::Maximize,
    initialization::Random,
    operators::sequential::combinator::Fill,
    operators::sequential::mutation::RandomReset,
    termination::MaxGenerations,
};
use std::num::NonZero;

fn main() {
    let fitness_fn = |args: &[u32; 2]| (args[0] as usize) * (args[0] as usize) - (args[1] as usize);

    let mut ga = EvolutionaryAlgorithm::new(
        Random::new(),
        MaxGenerations::new(100),
        fitness_fn,
        Fill::from_population_size(RandomReset::new()),
        NonZero::new(500).unwrap(),
        rand::rng(),
        Maximize,
    );

    let result = ga.run();
    let best = result.population().best(&fitness_fn, &Maximize);
    println!("Best genome: {:?}, fitness: {:?}", best.genome(), best.fitness(&fitness_fn));
}

Builder Pattern

The GA can also be constructed incrementally with a builder:

use evolve::{
    algorithm::EvolutionaryAlgorithm,
    fitness::Maximize,
    initialization::Random,
    operators::sequential::combinator::Fill,
    operators::sequential::mutation::RandomReset,
    termination::MaxGenerations,
};
use std::num::NonZero;

fn main() {
    let fitness_fn = |args: &[u32; 2]| (args[0] as usize) * (args[0] as usize) - (args[1] as usize);

    let mut ga = EvolutionaryAlgorithm::builder(NonZero::new(500).unwrap())
        .initializer(Random::new())
        .termination(MaxGenerations::new(100))
        .fitness(fitness_fn)
        .operators(Fill::from_population_size(RandomReset::new()))
        .rng(rand::rng())
        .comparator(Maximize)
        .build();

    let result = ga.run();
}

Custom Operators

Implement GeneticOperator to define your own:

use evolve::{
    core::{context::Context, offspring::Offspring, state::State},
    operators::GeneticOperator,
};

struct MyOperator;

impl<G, F, Fe, R, C> GeneticOperator<G, F, Fe, R, C> for MyOperator {
    fn apply(&self, state: &State<G, F>, ctx: &mut Context<Fe, R, C>) -> Offspring<G, F> {
        // your logic here
        todo!()
    }
}

Parallel Execution

Enable the parallel feature to run operators across multiple threads:

[dependencies]
evolve = { version = "0.3", features = ["parallel"] }

Parallel operators distribute work across a thread pool using the pooled crate:

use evolve::{
    algorithm::EvolutionaryAlgorithm,
    fitness::Maximize,
    initialization::Random,
    operators::parallel::combinator::Fill,
    operators::sequential::mutation::RandomReset,
    termination::MaxGenerations,
};
use std::num::NonZero;

fn main() {
    let mut ga = EvolutionaryAlgorithm::builder(NonZero::new(500).unwrap())
        .initializer(Random::new())
        .termination(MaxGenerations::new(100))
        .fitness(|g: &[u8; 4]| g.iter().map(|x| *x as u32).sum::<u32>())
        .operators(Fill::new(RandomReset::new(), 500))
        .rng(rand::rng())
        .comparator(Maximize)
        .build();

    let result = ga.run();
}

A Runtime is created automatically (defaulting to available_parallelism threads) or can be configured via the builder's .runtime() method:

// Use a custom thread pool size
let ga = EvolutionaryAlgorithm::builder(NonZero::new(500).unwrap())
    // ...
    .runtime(pooled::Runtime::new(4))
    .build();

Grammatical Evolution

Evolve programs by mapping integer codon sequences through a grammar. Each individual is a variable-length Vec<u8> (or any unsigned integer); codons select productions during derivation, producing a program that is then evaluated for fitness.

Using the grammar! macro (zero-cost, compile-time)

use evolve::grammar;
use evolve::phenotype::bytecode::{Bytecode, BytecodeBuilder, Instruction};
use evolve::fitness::GeFitness;

grammar! {
    grammar MyGrammar;
    symbol Sym;
    start Expr;

    Expr => [Expr, Expr, BinOp] | [Val];
    BinOp => [Add] | [Sub];
    Val => [X] | [One];
}

impl Instruction for Sym {
    type Value = f64;
    type Input = f64;
    fn execute(&self, stack: &mut Vec<f64>, input: &f64) {
        match self {
            Sym::X => stack.push(*input),
            Sym::One => stack.push(1.0),
            Sym::Add => { let b = stack.pop().unwrap_or(0.0); let a = stack.pop().unwrap_or(0.0); stack.push(a + b); }
            Sym::Sub => { let b = stack.pop().unwrap_or(0.0); let a = stack.pop().unwrap_or(0.0); stack.push(a - b); }
            _ => {}
        }
    }
}

Using the runtime Grammar<T> builder

use evolve::grammar::Grammar;

let grammar = Grammar::builder()
    .rule("expr", &[&["expr", "expr", "op"], &["x"], &["1"]])
    .rule("op", &[&["+"], &["-"]])
    .start("expr")
    .build();

Running GE

use evolve::{
    algorithm::EvolutionaryAlgorithm,
    fitness::{Minimize, GeFitness},
    initialization::RangedRandom,
    operators::sequential::{
        combinator::{Combine, Fill, Pipeline},
        crossover::SinglePoint,
        mutation::RandomReset,
        selection::Tournament,
    },
    termination::MaxGenerations,
};
use std::num::NonZero;

let fitness = GeFitness::<MyGrammar, u8, f64, _, BytecodeBuilder<Sym>>::new(
    MyGrammar,
    3,
    |program: &Bytecode<Sym>| {
        // Score the program on test cases
        (program.run(&2.0) - 5.0).abs() // target: f(2) = 5
    },
    f64::MAX,
);

let mut ea = EvolutionaryAlgorithm::new(
    RangedRandom::<u8>::new(10..50),
    MaxGenerations::new(500),
    fitness,
    Fill::from_population_size(Pipeline::new((
        Combine::new((
            Tournament::new(NonZero::new(3).unwrap()),
            Tournament::new(NonZero::new(3).unwrap()),
        )),
        SinglePoint::<u8>::new(),
        RandomReset::<u8>::new(),
    ))),
    NonZero::new(200).unwrap(),
    rand::rng(),
    Minimize,
);

let result = ea.run();

Variable-length operators

GE benefits from variable-length genomes. Use RangedRandom for initialization (produces genomes of random length within a range) and the variable-length mutation operators SegmentDuplication and SegmentDeletion to explore different codon lengths during evolution.

Collectors

The run() method uses a Standard collector that records best fitness and timing per generation:

let result = ea.run();
println!("generations: {}", result.generations());
println!("total time: {:?}", result.total_duration());
println!("best fitness per gen: {:?}", result.best_fitness());
println!("gen 50: {:?}", result.generation(50));

Use run_with for a different collector:

use evolve::collector::{Basic, NoOp, Logger};

let result = ea.run_with(Basic);           // just population + generation count
ea.run_with(NoOp);                         // discard everything, returns ()
ea.run_with(Logger::default());            // prints each generation, returns ()
ea.run_with(Logger::with_collector(        // prints + collects
    NonZero::new(10).unwrap(),
    Standard::default(),
));

Implement the Collector trait for custom data collection:

use evolve::collector::Collector;
use evolve::core::state::State;

struct DiversityCollector(Vec<usize>);

impl<Fe, C> Collector<[u8; 4], u32, Fe, C> for DiversityCollector {
    type Result = Vec<usize>;

    fn on_generation(&mut self, state: &State<[u8; 4], u32>, _fe: &Fe, _cmp: &C) {
        let unique = state.population().iter()
            .map(|ind| ind.genome())
            .collect::<std::collections::HashSet<_>>()
            .len();
        self.0.push(unique);
    }

    fn finalize(self, _state: State<[u8; 4], u32>) -> Self::Result {
        self.0
    }
}

Experiment Runner

Run multiple independent trials for statistical analysis:

use evolve::experiment::Experiment;
use evolve::collector::standard::Standard;

let results = Experiment::new(
    || EvolutionaryAlgorithm::new(/* ... */),
    30,
    || Standard::default(),
).run();

for (i, r) in results.iter().enumerate() {
    println!("Run {}: {} gens, {:?}", i, r.generations(), r.total_duration());
}

Contributing

Contributions are welcome! Feel free to open an issue for bug reports, feature requests, or questions. Pull requests are also appreciated.

AI Disclosure

AI was used only to assist with writing comments, writing tests, writing examples, and as a rubber duck to discuss ideas with. All final decisions and code were written by a human.