Contribute
Extension Points
Seven things you can add, and they cost wildly different amounts. Knowing which axis you are on before you start saves the most time of anything on this page.
The Cost of Each Axis
| Add a… | Integration steps | Because |
|---|---|---|
| Objective | Seven steps, four required, but only one dispatch arm, which is what makes it the cheapest by a wide margin. In Python, no Rust at all. | The objective is erased to a trait object before dispatch, so it never enters the strategy × genome match. |
| Genome | Seven steps, six required, one of them a dispatch arm. | The strategy match is generic over the genome, so evolvers are untouched. |
| Strategy | Seven steps, three required, one of them an arm in the strategy match. | The genome match is untouched, for the same reason in reverse. |
| Selection Scheme | Six steps, four required. | Selection is already an enum for exactly this reason, and it answers only "who breeds", so no strategy or genome is touched. |
| Crossover Operator | Six steps, four required; one is a compatibility pair per genome. | [crossover] is a shared section, so the operator has to declare which
representations can honour it. The match is exhaustive on purpose: adding a variant
stops it compiling until you do. |
| Mutation Operator | Four steps, three required, per genome: the chain exists twice. | What counts as "one mutation" differs by representation, so there is no shared enum. Adding one for edge-edit does not touch SDA. |
| Scope | Six steps, four required. | Who is eligible for one breeding event. Its parameters are its own, and that separation is why any scheme now pairs with any strategy. |
| Replacement Policy | Three steps, the shortest chain here. | Who gets overwritten, and steady-state's alone: generational rebuilds its population and
never asks. A policy may draw (random does), so it shares the run's seeded
stream. |
Why the Two Axes Are Not Symmetric
Genome cannot be a trait object. Its mutation and crossover methods are generic over the
random number generator, crossover takes a mutable Self, cloning requires a known
size, and its context is an associated type that differs per representation. Any one of those
rules out dyn Genome. So strategy × genome stays a match.
Fitness has none of those problems: no generic methods, no Self in argument
position, and it is thread-safe through its supertrait. So the objective is boxed before the
evolver is built, and a new objective never touches dispatch at all.
- A forwarding implementation of
Fitnessfor the boxed trait object, which must live beside the trait, because the orphan rule rejects it anywhere else. - Every method forwarded, including the defaulted ones. Omitting either compiles and
is wrong: dropping
directionmakes a maximizing objective run backwards, and droppingevaluate_batchmakes a Python objective fall back to a per-individual fan-out, which is the exact thing the batched design exists to prevent. - A factory, not a value. Replicates need per-run objective instances, so the erasing
match is re-run once per replicate. One shared box handed to
nconcurrent runs is the shared-counter bug that destroys reproducibility under load.
Two Front Doors, and Neither Is a Fallback
| Route | For | |
|---|---|---|
| Config-driven | Write a config, register a Python callable, call run. |
Most users, and any prototype. No Rust, no compilation. |
| Library | Depend on get as a crate, implement Fitness for your own type,
and drive an evolver directly. |
A hot native objective, without forking GET. |
The library route needs no dispatch arm, because it does not go through dispatch. The
evolver's run is generic over the objective, so a caller who already holds a concrete
type instantiates the evolver with it directly. Dispatch exists to turn a config document
into concrete types; a library consumer is not a config consumer, and the two doors are
independent.
What Dispatch Must Keep Public
Dispatch must not become the only way to construct a run. Narrowing any of these would kill the library route silently, with no compile error inside the crate:
- the
Fitnesstrait, andDirection - the genome implementations and their context types
SharedEvolutionContext, each strategy's type context, andEvolver::newEvolver::runandEvolutionOutcome
Assembling a population and contexts by hand is genuinely awkward today, and that is a real cost of the library route, and it may deserve a builder later. It is not a reason to route user objectives through the config enum instead.
The One Rule That Is Not Optional
express_and_score. The engine never calls
Fitness::evaluate or evaluate_batch directly, anywhere. That single
function is both the orientation point and the NaN gate, and a direct call bypasses
both, producing values that compare backwards under a maximizing objective, and letting a
NaN through that will take over the population. Neither failure announces itself.
Any new evolution strategy scores its population through this function or it is wrong. Detail on
Fitness & Output.
The Steps Are a Checklist, Not a Complete List
The common case is a parameter. Scope::RandomSubset { size } needs
size checked at load, so Config::validate_scope has an arm for it: a
scope with no parameters would never have sent you there. If your variant carries a number, look
for the Config::validate_* function covering its section and add the constraint,
whether or not a marker mentions one.
Practical Notes for Any of Them
- Add the key to
config.example.toml. The shipped example is parsed and validated by the test suite, so it can never silently rot, and a broken example fails the suite rather than a user's afternoon. - Mirror any new config field on the Python side, and add its attribute path to the error-mapping table, or the test that scrapes the validator for field names will fail.
- Never use a bare default on a count where zero is meaningless. Named default functions exist for precisely that reason.
- A top-level config field added on one side only fails to compile, because the round-trip test destructures the config exhaustively with no catch-all. That is deliberate, and it is the best guard in the codebase.