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.

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.

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.

AxisQuestionConfig
Scopewho is eligible for this event[scope]
Selectionwho among them becomes a parent[selection]
Replacementwho 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").

Scope is an enum, not a trait, so it cannot be extended from outside the crate. A program that merely depends on GET gets the scopes GET ships. Adding one means editing your own copy, which is what the steps below assume.

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
StepWhereWhat
1get/src/evolver/scope.rs:12 the variant, and whatever it needs to locate a slice
2get/src/evolver/scope.rs:52 the arm filling the buffer with your indices
3get/src/config.rs:77 the variant a user names under [scope], plus any constraint on it
4get/src/dispatch.rs:684 the arm mapping that config variant onto the engine one
5get/src/py_config.rs:186 and :665 the Python mirror, optional
6config.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.

Three contracts your arm must honour, none of which the compiler checks.

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.

If your scope should be Rust-only, you are finished here. Steps 1 and 2 are the whole engine; everything below exists to let a config file name it.

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.

A field you validate also needs a line in 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

See Add a Selection Scheme and Add a Replacement Policy.