Advanced Extensions
Add a Scope
Choosing which slice of the population a single breeding event may touch. Six edits across five files, and no strategy, genome or selection scheme is touched: scope is one of three independent axes, and it answers only this question.
The One Question a Scope Answers
A scope produces a list of population indices. Everything downstream works inside that list:
[selection] picks parents from it, [replacement] picks who the children
overwrite in it. The scope itself has no opinion about fitness; Global does not even
read it.
| Axis | Question | Config |
|---|---|---|
| Scope | who is eligible for this event | [scope] |
| Selection | who among them becomes a parent | [selection] |
| Replacement | who among them is overwritten | [evolution] replacement |
A variant's parameters are its own. size belongs to [scope] and
nothing else reads it. That is the whole reason this axis exists as its own enum: a scheme with no
tournament (best, say) still has to be able to say how large a scope it wants, and
it cannot if that number lives under [selection].
What the User Ends Up Writing
Adding a scope called Neighbourhood is what makes this valid:
[scope]
type = "neighbourhood"
radius = 3
You never write the string "neighbourhood" anywhere in the code; it is derived from
the variant name, converted to snake_case, and the keys under it are your struct's field names.
Every config carries a [scope] block, generational included
(type = "global").
The Six Steps
Every step is marked at its own site. One command lists the chain:
git grep -n "ADD A SCOPE STEP" get/src config.example.toml # 6 steps, 7 sites
| Step | Where | What |
|---|---|---|
| 1 | get/src/evolver/scope.rs:12 |
the variant, and whatever it needs to locate a slice |
| 2 | get/src/evolver/scope.rs:52 |
the arm filling the buffer with your indices |
| 3 | get/src/config.rs:77 |
the variant a user names under [scope], plus any constraint on it |
| 4 | get/src/dispatch.rs:684 |
the arm mapping that config variant onto the engine one |
| 5 | get/src/py_config.rs:186 and :665 |
the Python mirror, optional |
| 6 | config.example.toml:167 |
a commented-out block naming your variant: optional, and the step people skip |
Steps 1 and 2: The Engine Side
Both are in get/src/evolver/scope.rs. Add the variant, then the arm that draws it:
// step 1: evolver/scope.rs:12
Neighbourhood { radius: usize },
// step 2: evolver/scope.rs:52
Scope::Neighbourhood { radius } => {
let centre = rng.random_range(0..population_len);
for offset in 0..=(radius * 2) {
out.push((centre + offset) % population_len);
}
}
draw_into writes into the caller's buffer rather than returning a Vec,
because generational draws a scope per breeding pair and an allocation per pair is a cost this
abstraction has no reason to add. out is already cleared for you, once, before
the match; do not clear it again in your arm.
- Every index must be in range for
population_len. Nothing downstream re-checks, so an out-of-range index is an assertion in a debug build and a silent misread otherwise. - Keep them distinct if any replacement policy is to name positions.
RandomSubsetdraws without replacement precisely so that "the worst two members" means something; over a multiset it does not. The wrapping example above can repeat an index whenradius * 2 + 1exceeds the population, which is exactly the kind of thing to decide deliberately rather than discover. - Take every random value from
rng. Any other source breaks seeded reproducibility in the way hardest to notice: the run still works, it just cannot be repeated.
Leave the indices unordered. Anything needing them ranked sorts its own copy, which for a small subset beats sorting a global scope nobody was going to read in order.
Steps 3 and 4: The Config Side
One change, two files. The config variant, and the arm mapping it onto the engine variant:
// step 3: config.rs:77
Neighbourhood { radius: usize },
// step 4: dispatch.rs:684
ScopeConfig::Neighbourhood { radius } => {
Scope::Neighbourhood { radius: *radius }
}
These two are one change split across two files: a config variant nothing constructs is dead code, and an arm for a variant that does not exist will not compile. Neither can be forgotten quietly.
If your variant has parameters that can be wrong (a radius that must be at least one), constrain
them in Config::validate_scope, in get/src/config.rs. Only your
own: size belongs to RandomSubset and nothing else reads it, which is
the separation this axis exists for.
python_attribute_path
(get/src/py_config.rs), or a Python caller who trips your check sees an error naming a
TOML field they never wrote. The test
every_validation_field_maps_to_a_python_attribute is what catches a missing one, but
only for fields that reach validation.
Steps 5 and 6: Python, and Optional
Skipping these makes the scope TOML-only: it still runs from a config file and from the Rust
library, and a Python caller simply cannot name it. Step 5 is the mirrored variant on
PyScopeConfig (get/src/py_config.rs:186) and its arm in the conversion
that writes the [scope] table (:665):
// step 5: py_config.rs:186
#[pyo3(constructor = (radius))]
Neighbourhood { radius: usize },
// step 5: py_config.rs:665
PyScopeConfig::Neighbourhood { radius } => {
table.insert("type".to_string(), Value::String("neighbourhood".to_string()));
table.insert("radius".to_string(), integer("radius", *radius)?);
}
Write your own parameters and no other block's: the arm is building the
[scope] table alone. Step 6 is a commented-out block in
config.example.toml:167.
Step 5 is the one nothing checks. A scope missing its Python mirror does not fail to compile and does not fail a test. It is simply absent from the Python API, and the first person to notice is a user who cannot find it.
Testing It
A scope is a pure function from a population size and an RNG to a list of indices, so it tests directly with no evolver in the way. Four assertions cover it:
// in scope.rs's own `mod tests`, beside the two that are there
#[test]
fn neighbourhood_stays_in_range_and_is_reproducible() {
let scope = Scope::Neighbourhood { radius: 2 };
let mut out = Vec::new();
let mut rng = StdRng::seed_from_u64(7);
scope.draw_into(20, &mut out, &mut rng);
assert!(out.iter().all(|&i| i < 20)); // in range
assert_eq!(out.len(), 5); // the size it promises
let first = out.clone();
let mut fresh = StdRng::seed_from_u64(7);
scope.draw_into(20, &mut out, &mut fresh);
assert_eq!(out, first); // same seed, same scope
}
Read the two tests already in that module first. a_global_scope_is_every_index_and_
consumes_no_randomness shows how to assert that a scope leaves the RNG stream untouched,
worth copying if yours is deterministic, because a scope that quietly draws is what makes two runs
with the same seed diverge. a_random_subset_is_distinct_and_the_size_asked_for is the
distinctness assertion: collect into a HashSet and check the length is unchanged.
Then add the keys to config.example.toml, which the test suite parses and validates,
a block that does not parse fails the suite rather than a user's afternoon.
The Other Two Axes
If what you want is who becomes a parent or who gets overwritten, those are their own chains:
git grep -n "ADD A SELECTION STEP" get/src config.example.toml # 6 steps, 7 sites
git grep -n "ADD A REPLACEMENT STEP" get/src # 3 steps, 6 sites