Advanced Extensions
Add a Replacement Policy
Choosing which members of a scope a breeding event's children overwrite. Three steps, the shortest chain in GET, and the only one that belongs to a single strategy.
(for SteadyState), so a grep across all the chains at once
does not hand you a step that is not yours.
The One Question a Policy Answers
A policy is handed the scope a breeding event drew and the fitnesses of everyone in it, and returns which positions the children take. It is the counterpart to selection: that answers who breeds, this who dies, and both work inside the same slice. Keeping them apart is what lets any selection scheme pair with any policy: a scheme no longer has to supply a replacement draw it has no theory for.
A policy may consume randomness. worst reads the scope's fitnesses and
nothing else; random draws from the run's seeded stream. Either is reproducible from
a seed, but adding or removing a drawing policy changes what every later draw in the run sees, so
runs are only comparable against others using the same policy.
Self-Elitism Rests Here
Worst takes the scope's least fit members, never its best. That is what makes
steady-state self-elitist, and note it is a property of the policy, not of the selection
scheme, which is why it holds whichever scheme picked the parents. Random gives that
guarantee up by design: it can draw the scope's best, so a run's best fitness can fall.
What the User Ends Up Writing
[evolution]
type = "steady_state"
num_mating_events = 5000
replacement = { type = "random" } # or "worst", the default
It is read from the strategy's own table rather than a section of its own, because it belongs to
whichever strategy displaces individuals, the same way elite_count belongs to
generational. Absent means worst.
The Three Steps
git grep -n "ADD A REPLACEMENT STEP" get/src # 3 steps, 6 sites
| Step | Where | What |
|---|---|---|
| 1 | get/src/evolver/replacement.rs:15 |
the variant, and any parameters it needs |
| 2 | get/src/evolver/replacement.rs:61 |
the arm naming who the children overwrite; the match is exhaustive |
| 3 | get/src/config.rs:95, get/src/dispatch.rs:698,
get/src/py_config.rs:170 and :641 |
what a user names under replacement, the arm building it, and the Python
mirror |
Step 3 is one step across four sites rather than four steps, because none of them is a decision: once the variant and its arm exist, the rest is naming it in each of the three places a name can arrive from. The Python half is optional; leaving it out makes the policy TOML-only.
Steps 1 and 2: The Engine Side
Both are in get/src/evolver/replacement.rs. Add the variant, then the arm that names
the victims:
// step 1: evolver/replacement.rs:15
Tournament { size: usize },
// step 2: evolver/replacement.rs:61
Replacement::Tournament { size } => {
// draw `size` members, overwrite the least fit of them
}
The signature you are filling in is:
fn pick<R>(&self, scope: &[usize], fitnesses: &[f64], count: usize, rng: &mut R) -> Vec<usize>
where
R: Rng + ?Sized
- Return indices into the population, not into
scope, and in the order children should take the slots.scopeis itself a list of population indices, so returning a position within it is a bug that type-checks. - Draw only from
rng, never from a thread RNG or a clock. It is the run's seeded stream, shared with selection, scope and mutation, which is what makes a run reproducible from its seed. A policy that draws is fine (Randomdoes) but two runs are only comparable if they used the same policy, because a draw here changes what every later draw in the run sees.
Say at the variant what your policy gives up. Worst is what makes a strategy
self-elitist; Random can overwrite the scope's best and says so in its own doc
comment. Leaving that to be discovered later is how a run quietly stops improving.
Step 3: The Three Places a Name Arrives From
// step 3: config.rs:95
Tournament { size: usize },
// step 3: dispatch.rs:698
ReplacementConfig::Tournament { size } => Replacement::Tournament { size: *size },
// step 3: py_config.rs:170 (optional)
#[pyo3(constructor = (size))]
Tournament { size: usize },
// step 3: py_config.rs:641 (optional, the matching arm)
PyReplacementConfig::Tournament { size } => {
table.insert("type".to_string(), Value::String("tournament".to_string()));
table.insert("size".to_string(), integer("size", *size)?);
}
The first two are one change split across two files and neither compiles without the other. The second two are the Python mirror, and nothing checks them: a policy missing its mirror compiles clean and passes every test, and is simply absent from the Python API.
Testing It
pick takes a scope, a fitness slice and an RNG, and returns a list of indices, with no
evolver, nothing else to set up:
#[test]
fn tournament_returns_population_indices_from_the_scope() {
let scope = [3, 7, 11, 15];
let fitnesses = [0.0; 16];
let mut rng = ChaCha8Rng::seed_from_u64(7);
let victims = Replacement::Tournament { size: 2 }.pick(&scope, &fitnesses, 2, &mut rng);
assert_eq!(victims.len(), 2);
assert!(victims.iter().all(|v| scope.contains(v)));
}
The existing test beside it, the_worst_are_returned_worst_first, is the one to read
before writing yours: it pins the ordering contract that the children's slot assignment depends
on. If your policy draws, seed a fixed RNG so the test is reproducible, and read
random_returns_distinct_members_of_the_scope for the distinctness assertion every
drawing policy owes: two victims that coincide mean one child overwrites the other.
The Limit Worth Knowing Before You Start
Oldest, or anything keyed on lineage, tenure or distance
from a parent, is not a variant plus an arm: it needs that quantity recorded first, which is a
change to the engine rather than to this enum. Check what you need exists before writing the
variant.
The Other Two Axes
git grep -n "ADD A SCOPE STEP" get/src config.example.toml # 6 steps, 7 sites
git grep -n "ADD A SELECTION STEP" get/src config.example.toml # 6 steps, 7 sites
See Add a Scope and Add a Selection Scheme.