Advanced Extensions
Add a Genome
A third representation: a different way of describing a graph compactly. Seven steps, six of them required, and the evolvers are untouched, because the strategy match is generic over the genome type.
What You Have to Implement
pub trait Genome: Clone + Send + Sync {
type Context: Send + Sync;
fn express(&self, context: &Self::Context) -> Graph;
fn crossover<R: Rng + ?Sized>(&mut self, other: &mut Self, rng: &mut R);
fn mutate<R: Rng + ?Sized>(&mut self, context: &Self::Context, rng: &mut R);
fn print(&self) -> String;
}
| Method | Must |
|---|---|
express |
Build a graph from the genome and the run context. Deterministic: the same genome and context must always give the same graph, or reproducibility breaks and the two strategies stop agreeing about the winner. |
crossover |
Recombine in place, leaving one child in each parent. Both are kept. Tolerate unequal sizes defensively even though nothing produces them. |
mutate |
Apply exactly one mutation. Not a random number of them; the engine decides how many times to call you. |
print |
Render the genome as a string. This is the type-erasure hook: it is how a non-generic entry point records which individual won without knowing the representation. |
Context |
Carry run-level expression configuration, and be the authority on graph size and edge cap. It is shared by reference across every worker, so it must be safe to share and must not be mutated during expression. |
max_mutations mean nothing for that representation, and nothing
reports the disagreement, so a user comparing two representations at the same setting silently
compares two different things. See Variation & Selection.
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 representation is the
[genome] section. Adding one called MyGenome is what makes this valid:
[genome]
type = "my_genome"
some_dimension = 256
You never write the string "my_genome" 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 step 7 below is what makes a genome 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 GENOME STEP" get/src config.example.toml # 7 steps, 11 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.
| Step | Where | What |
|---|---|---|
| 1 | get/src/genomes/genome.rs:8 |
Implement Genome for your type, in your own module
(genomes/my_genome.rs): express,
crossover, mutate, print, and the
Context associated type. All of them are required, and mutate
is where each of your mutation kind's variants is performed. |
| 2 | get/src/genomes/genome.rs:54 and
:90 |
Declare the Context type your genome named, struct and fields pub,
and the mutation kind it carries. The enum is what makes the operator selectable from a
config; a genome that always varies itself the same way needs none. |
| 3 | get/src/genomes.rs:8 |
pub mod your module, then extend both re-export lists. |
| 4 | get/src/config.rs:149 and :732 |
A GenomeConfig variant plus the struct it carries, and its arm in
validate_genome. |
| 5 | get/src/dispatch.rs:273 |
A start builder beside sda_start: validate the dimensions once, mint
population_size individuals, build the context. |
| 6 | get/src/dispatch.rs:456 |
One arm in the genome match, calling your step-5 builder. The strategy match is not touched; it is generic over the genome. |
| 7 | get/src/py_config.rs:268, :476 and
config.example.toml:186 |
Optional. The Python-side variant and its to_toml_value arm, plus
one line in python_attribute_path per field step 4 validates by name, plus
a commented-out [genome] block in the example config. |
Steps 1 to 3: The Representation
// step 1: genomes/genome.rs:8
impl Genome for MyGenome {
type Context = MyContext;
fn express(&self, context: &Self::Context) -> Graph { ... }
fn crossover<R: Rng + ?Sized>(&mut self, other: &mut Self, rng: &mut R) { ... }
fn mutate<R: Rng + ?Sized>(&mut self, context: &Self::Context, rng: &mut R) {
match context.mutation {
MyMutation::SomeVariant => { /* exactly one change to self */ }
}
}
fn print(&self) -> String { ... }
}
// step 2a: genomes/genome.rs:54
#[derive(Clone, Debug)]
pub struct MyContext {
pub num_nodes: usize,
pub some_setting: f64,
pub mutation: MyMutation,
}
// step 2b: genomes/genome.rs:90
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MyMutation {
#[default]
SomeVariant,
}
// step 3: genomes.rs:8
pub mod my_genome;
pub use my_genome::{MyGenome, MyOperators};
pub use genome::{MyContext, MyMutation};
The context is run configuration only, never evolved state. The test is "can variation change it": anything variation cannot change belongs on the context rather than on the genome.
mutate must make exactly one change per call. The engine's
max_mutations counts calls, not changes, so a genome that applies several silently
makes the user's configured rate untrue and nothing reports it.
Steps 4 to 6: Config and Dispatch
// step 4a: config.rs:149
MyGenome(MyGenomeConfig),
// step 4b: config.rs:732
GenomeConfig::MyGenome(mine) => {
if mine.some_dimension == 0 {
return Err(invalid("some_dimension", "must be at least 1"));
}
}
// step 5: dispatch.rs:273
pub(crate) fn my_genome_start<R: Rng + ?Sized>(
config: &Config,
mine: &MyGenomeConfig,
rng: &mut R,
) -> PyResult<(MyContext, Vec<MyGenome>)> { ... }
// step 6: dispatch.rs:456
GenomeConfig::MyGenome(mine) => {
let (genome_context, population) = my_genome_start(config, mine, &mut rng)?;
...
}
Step 7: Python, and Optional
Leaving it out costs nothing elsewhere; the genome is then configurable from a TOML file but
absent from the Python API. The variant goes at get/src/py_config.rs:476, and the
python_attribute_path lines at :476.
Half-finishing step 7 is the trap. If step 4's validation raises a field by name and
python_attribute_path has no line for it, a Python caller gets an error naming a
TOML field they never wrote. every_validation_field_maps_to_a_python_attribute is
the test that catches it.
Then add the keys to config.example.toml, which the test suite parses and validates
so a broken example fails the build.
Design Questions Worth Answering First
Where Does the Size of the Graph Come From?
From your context, and from nowhere else. Config carries network_size and
max_edge_multiplicity, but only the dispatch layer reads them; a second copy on the
context could drift, and nothing would report the disagreement. Whatever your context exposes is
the authority during expression.
Does Anything Derive From the Edge Cap?
It might. The SDA Alphabet is derived as
max_edge_multiplicity + 1 so that every character it can emit is a legal edge weight
and nothing is ever clamped. If your representation produces weights in any indirect way, work
out what the equivalent invariant is before writing the constructor, because the failure is
silent in both directions: too wide and the graph biases toward the cap, too narrow and the
upper weights are unreachable.
What Is Run Configuration, and What Is Genome Data?
If mutation and crossover never touch it, it belongs on the context, not in the genome. SDA's
init_state is the worked example: it is expression configuration, so putting it in
the genome would waste mutation budget on something that should not vary within a run.
Can Expression Fail?
It cannot: express returns a graph, not a result. That is deliberate, and the
graph's silent handling of impossible edits is what makes it workable: an out-of-range vertex, a
self-loop, or an edit whose preconditions fail is simply a no-op. Push everything that can
fail into the constructor and the dimension checks, where it surfaces at startup rather than
mid-run.
Degenerate Sizes
Decide what your representation does at zero and one node before you find out by accident. Both
existing genomes return an empty graph without doing any work, and SDA's out-of-range
init_state bug specifically hides at two nodes, because the expression loop never
runs there.
Testing It
- Express twice, compare. Determinism is the property everything else rests on.
- Mutate
ntimes from a fixed seed and count what changed: the one-mutation contract is exactly the kind of thing that is right on day one and wrong after the first refactor. - Crossover two known parents and check both children. Losing the second child is a classic silent halving of your crossover's effect.
- Run it through both strategies end to end at a tiny population and a handful of generations. Dispatch is where a mismatched context shows up.
- Hand-build a population and drive an evolver directly, which is possible precisely because the evolver takes a population rather than minting one.
What You Get for Free
Everything except expression. Selection, both evolution strategies, the whole
variation scheme, orientation and NaN rejection,
logging, all four built-in objectives and any Python objective work with your representation the
moment dispatch can build it, because none of them know what a genome is. That is what the
static-generic design buys, and it is the reason a new genome costs six required steps rather than
four files.