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.

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.

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;
}
MethodMust
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.
The one-mutation rule is a contract, not a convention. A genome that rolls its own count internally makes 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.

StepWhereWhat
1get/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.
2get/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.
3get/src/genomes.rs:8 pub mod your module, then extend both re-export lists.
4get/src/config.rs:149 and :732 A GenomeConfig variant plus the struct it carries, and its arm in validate_genome.
5get/src/dispatch.rs:273 A start builder beside sda_start: validate the dimensions once, mint population_size individuals, build the context.
6get/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.
7get/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.

Learn from the existing wart here. SDA's derived-alphabet invariant is upheld by convention rather than by the type system: the dispatch layer always derives it correctly, but the underlying constructor still accepts an arbitrary alphabet size, and expression does not check the genome's alphabet against the context's cap. A hand-assembled population via the library route can therefore silently reintroduce the exact bias the design exists to prevent. If your representation has a similar coupling, encode it in the constructor rather than in a comment.

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

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.