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.

Not part of the v0.9 release. This chain isn't finished and documented yet; only adding an objective is ready to use today. This note is removed once the chain is finished and documented.

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>;
}
PieceIs
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.

StepWhereWhat
1get/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.
2get/src/config.rs:58 An EvolutionConfig variant carrying your stopping condition, plus any axis that is yours rather than every strategy's.
3get/src/config.rs:644 Optional. A constraint arm in validate_evolution_and_selection. A strategy with nothing to constrain adds no arm.
4get/src/dispatch.rs:586 One arm building your TypeContext and calling your evolver's new and run. The genome match is not touched.
5get/src/dispatch.rs:610 Usually nothing, and that is the point. erase is generic over the genome with no match on strategy inside it.
6get/src/py_config.rs:153 and :621 Optional. The Python-side variant, and its to_toml_value arm.
7config.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

1. Score through 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.
2. Everything inside your evolver is as lower-is-better: lower is better. Do not convert on the way in or on the way out. The fitness arrays, the stats rows and the outcome are all lower-is-better; conversion happens once at each edge of the system, and a strategy that helpfully converts something in the middle reintroduces the double-flip the design removed.
3. Use the shared variation helper. The crossover roll and the two mutation rolls live in one place so that strategies cannot drift apart on what a mutation means. They already did drift once, on selection sampling, which is why this is stated as a rule rather than left to taste.
4. Seed a pinned generator. Seed 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.

The engine's own assertions are backstops, not validation. They exist for direct library use (tests, embedding) where no config was involved. A config-driven run must never reach one, because a bad config has to surface as a proper exception naming the field, not as a panic crossing the FFI boundary. Note the validator constrains the scope against the population: [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

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.