Advanced Extensions
Add a Strategy
A third evolution strategy: a different answer to "who breeds and who gets replaced". Seven steps, three of them required, and the genome match is untouched, because your evolver is generic over the representation.
What You Have to Implement
pub trait Evolver<G: Genome> {
type TypeContext;
fn new(shared: SharedEvolutionContext<G>, type_context: Self::TypeContext, population: Vec<G>) -> Self;
fn run<F: Fitness>(&mut self, fitness: &F, seed: u64) -> EvolutionOutcome<G>;
}
| Piece | Is |
|---|---|
TypeContext |
Whatever your strategy needs and the others do not. The generational one carries its generation count and elite count, the steady-state one its mating-event count. |
SharedEvolutionContext |
What every strategy needs: the genome context; crossover_rate,
mutation_rate and max_mutations; and the configured
scope, selection and crossover operator. Nothing
else; see below. |
population |
Handed in, already built. You never mint genomes. |
run |
The loop. Returns the best genome, its expressed graph, its fitness, and the run history. |
What the User Ends Up Writing
A GET run is configured by a TOML document. See the
Python: TOML File for its full shape, and
Python: Config Objects for how to run one. The strategy is the
[evolution] section. Adding one called MyStrategy is what makes this
valid:
[evolution]
type = "my_strategy"
num_my_events = 500
You never write the string "my_strategy" anywhere in the code. It is derived
from your variant's name, converted to snake_case. Name the variant and the config name follows;
the keys under it are your struct's field names, the same way.
This is not specific to the command-line route. The same document is read by the CLI and by the Rust library, and a Python caller builds the same section through a config object instead of typing it. Skipping the optional Python step below is what makes a strategy TOML-only.
The Seven Steps
Every location carries a literal marker in the source, so you can jump to any step by searching its number. The line numbers below were correct when this page was written; if one has moved, the marker is the anchor that has not.
git grep -n "ADD A STRATEGY STEP" get/src config.example.toml # 7 steps, 8 sites
Each step is named at more than one site, because a marker points forward to the next; go by the step numbers rather than the number of lines the grep prints.
| Step | Where | What |
|---|---|---|
| 1 | get/src/evolver.rs:17 |
A new module beside the two that exist, its pub mod and re-export lines,
and the Evolver implementation inside it. |
| 2 | get/src/config.rs:58 |
An EvolutionConfig variant carrying your stopping condition, plus any axis
that is yours rather than every strategy's. |
| 3 | get/src/config.rs:644 |
Optional. A constraint arm in validate_evolution_and_selection. A
strategy with nothing to constrain adds no arm. |
| 4 | get/src/dispatch.rs:586 |
One arm building your TypeContext and calling your evolver's
new and run. The genome match is not touched. |
| 5 | get/src/dispatch.rs:610 |
Usually nothing, and that is the point. erase is generic over the
genome with no match on strategy inside it. |
| 6 | get/src/py_config.rs:153 and :621 |
Optional. The Python-side variant, and its to_toml_value arm. |
| 7 | config.example.toml:161 |
Optional, and the step people skip. A commented-out [evolution]
block for your strategy. The shipped example is what a user copies from, so a strategy
missing from it is one most people never find. |
Step 1: The Module
// step 1: evolver.rs:17
pub mod my_strategy;
pub use my_strategy::MyStrategyEvolver;
Steps 2 and 3: The Config Side
// step 2: config.rs:58
MyStrategy {
num_my_events: usize,
},
// step 3: config.rs:644, optional
EvolutionConfig::MyStrategy { num_my_events, .. } => {
if *num_my_events == 0 {
return Err(invalid("num_my_events", "must be at least 1"));
}
}
Put only what is yours in step 2. elite_count and replacement
are each one strategy's business (how generational carries individuals forward, and how
steady-state makes room for a child), and neither means anything to the other. What every
strategy shares, [scope] and [selection], already sits on
Config and needs nothing from you.
Steps 4 and 5: Dispatch
// step 4: dispatch.rs:586
EvolutionConfig::MyStrategy { num_my_events } => {
let type_context = MyStrategyContext {
num_my_events: *num_my_events,
};
let mut evolver = MyStrategyEvolver::new(shared, type_context, population);
erase(evolver.run(fitness, seed))
}
shared already carries the scope and the selection scheme, built once above
this match, so your strategy reads them without asking. Only what is yours goes in the
TypeContext.
Step 5 is usually not a step at all. erase is generic over the genome with
no match on strategy inside it; touch it only if your EvolutionOutcome needs
conversion the existing loop does not cover. None of the shipped strategies do.
Steps 6 and 7: Python and the Example, Both Optional
Leaving step 6 out costs nothing elsewhere; the strategy is then configurable from a TOML file but
absent from the Python API. The variant goes at get/src/py_config.rs:153 and its
to_toml_value arm at :621.
Step 7 is a commented-out [evolution] block in
config.example.toml:161, beside generational's and steady-state's. It is the step
people skip, and it costs nothing mechanically: the example file is simply what a user copies
from, so a strategy missing from it is one most people never find. The test suite parses and
validates that file, so a block that does not parse fails the build rather than a user's
afternoon.
The Rules You Must Not Break
express_and_score, always. The engine never calls
Fitness::evaluate or evaluate_batch directly, anywhere. That one
function is both the direction conversion and the NaN gate. Calling the trait
yourself produces values that compare backwards under a maximizing objective and lets a
NaN through that will win every tournament it enters, and neither failure announces
itself. Detail on Fitness & Output.
ChaCha8Rng from run's seed,
not the library's default generator, whose algorithm may change between releases, which would
defeat the entire purpose of a seed argument. The same seed must mean the same thing across
strategies.
Decisions Your Strategy Has to Make
How Much of the Population Turns Over?
This is the whole design space. Generational replaces everything but its elites each round; steady-state replaces two individuals per event. Both extremes work; the interesting part is what the choice implies:
| If you replace… | Then |
|---|---|
| the whole population | You must rescore everyone, which is expensive but makes every comparison fair under a stochastic objective, and gives you population-wide parallelism. |
| a few individuals | You score incrementally, which is cheap, but the population then carries stale fitnesses computed under different dice, and an individual that drew lucky keeps its inflated score until something rescores it. |
That trade-off is inherent rather than a defect of either implementation, and it is documented as an accepted limitation of steady-state. If your strategy scores incrementally, say so plainly in its documentation.
Where Does Elitism Come From?
Generational is explicitly elitist via elite_count. Steady-state is
structurally elitist (the tournament's best is never among the replaced) and needs no
setting at all. Either is fine; what is not fine is being accidentally elitist and not knowing
which.
How Often Do You Log?
Once per meaningful unit of progress, and log the starting population as iteration 0. Both
existing strategies do, which is what lets their histories share an axis. Steady-state logs one
row per population_size events rather than per event, because a 100,000-row history
is not a log; pick an interval that makes your history readable at your largest realistic run.
What Do You Report as the Best?
The best of the final population, matching both existing strategies, not a running best carried across the run. A running best under a stochastic objective is substantially a record of which iteration drew the luckiest sample, and if a configuration genuinely can lose its best individual, a report claiming otherwise describes a population that no longer exists. Compare by a total order rather than an unwrapped partial comparison.
Constraints and Validation
If your strategy needs something the others do not, put it in the validator, not in the evolver's
setup. Steady-state's [scope] size ≥ 4 is the worked example: two parents and the
two they replace must be distinct. It is strategy specific, so it is checked only when that
strategy is selected, and switching strategies can therefore make a previously valid scope
invalid. Note tournament_size and [scope] size are deliberately
independent: the first sizes a tournament and has no floor beyond 1, the second sizes the pool
that tournament draws from.
[scope] size must be at least 1 and at most population_size, because
distinct individuals cannot be drawn from fewer, while tournament_size is left
unbounded above, since a tournament samples with replacement and may legitimately exceed the
population.
Testing It
- Run it twice from one seed and compare the outcome and the whole history.
- Run it with a deliberately maximizing objective and confirm the reported fitness moves the right way: this is the test that catches a missed orientation, and nothing else will.
- Feed it an objective that returns
NaNand confirm the run fails rather than converging on the offender. - Check the history length against your logging cadence, including the iteration-0 row, and at a run shorter than one logging interval.
- Drive it from a hand-built population, which is possible precisely because evolvers take a population rather than minting one.
- Try both genomes. Your evolver is generic; the test that proves it is running it against both.
What You Get for Free
Every representation, every objective, both front ends, orientation, NaN rejection,
the variation scheme, and the statistics. Your strategy is a loop over machinery that already
exists, which is why five required steps is a realistic estimate rather than an optimistic one.