Learn

The Pipeline

One loop, and the two things it moves between: the graph every candidate becomes, and the genomes that write one.

The Loop

GET keeps a population of candidate solutions, scores them, lets the better ones produce children, and repeats. What makes it a graph evolution tool is what a candidate is and how it gets scored.

Config TOML, or built in Python validate once, before anything runs dispatch pick types, build the population the evolution loop, generational shape * express * genomes → graphs, in parallel score * one f64 per individual orient · reject NaN the single conversion point log a stats row best · mean · sd · ci cadence differs per strategy select * scope · select · replace crossover * + mutate * three rolls, engine-owned next generation EvolutionOutcome best genome · its graph · its fitness · the full history
The left column happens once per run. The dashed box is drawn in generational shape: one iteration scores the whole population and logs a row. Steady-state runs the same stages in the same order, but one mating event expresses and scores only the two new children, and logs once every population_size events rather than every iteration. * marks a stage you choose rather than one that is fixed: the genome you express, the objective you score against, the loop's shape, and each of scope, selection, replacement, crossover and mutation. Every one is set from the config and has an extension chain behind it; the unmarked stages are the engine.
StageDoes
Config Written as TOML or built from Python objects, which do not parse anything, they serialize to TOML, and that TOML is what Rust parses. One parser, one validator, so neither front end can accept a configuration the other would reject, and the generated TOML doubles as a provenance record.
Validate Once, before a single graph is allocated. A bad config comes back as an exception naming the field, never a panic.
Dispatch Picks the concrete strategy and genome types, and builds the starting population; the evolver never mints genomes itself.
Express Every genome becomes a graph, in parallel.
Score & orient Each graph gets one f64, flipped into the engine's orientation and checked for NaN.
Select, breed, mutate The configured scope, selection and replacement, plus three probability rolls, all owned by the engine.
Out The best genome, its graph, its fitness, and the run history.
Two rules worth memorising. Everything inside the engine is lower-is-better, whatever your objective was; as-measured values come back at the dispatch boundary on the way out. And the engine never calls a fitness function directly; one gate does all scoring, orientation and NaN rejection.

The Graph

A Graph is an undirected multigraph on a fixed node count, stored as a symmetric adjacency matrix of weights. Weight 0 means no edge; weight k means k parallel edges. Every graph carries a max_edge_multiplicity cap: set it to 1, the default, and you have an ordinary simple graph.

the graph weight 2 012 34 the storage 012 34 0 011 00 1 100 10 2 100 01 3 010 02 4 001 20 symmetric; the diagonal is always 0
Five nodes and five distinct neighbour pairs, six edge copies in total, since the 3–4 pair has multiplicity 2. The matrix is always network_size × network_size however sparse the graph is, which is why memory scales with the square of the node count.
The graph is silent, and that is the design. Self-loops, out-of-range vertices and over-cap weights are ignored or clamped rather than rejected, so an impossible edit is simply a no-op. Genome expression therefore never has to validate anything and cannot fail, and five of the nine edge-edit operations do nothing on an empty graph as a direct consequence.

The clamping is not reachable from Python. set_edge itself still collapses an over-cap weight, which is why the base-graph setter has to check at all, but set_base_graph inspects every multiplicity first and raises ValueError naming the offending edge, its weight and the configured cap. So feeding a cap-5 result into a cap-1 run fails loudly rather than quietly flattening every weight to 1. Keep max_edge_multiplicity the same or raise it when stacking runs. A Rust embedder calling set_edge directly still faces the clamp.

The node count never changes during a run, edges are undirected and carry no attributes, and storage is dense: reading or updating one edge is O(1), while a scan like degree is O(n) and get_edge_list O(n²), at a memory cost quadratic in the node count. Those are stated non-goals rather than gaps.

Reading a graph out gives get_edge_list(): every edge once as (u, v, multiplicity) with u < v, row-major. That is the order SDA expression writes in, and the shape a run hands back to Python. degree counts distinct neighbours, not edge copies; on a multigraph the two differ.

Genomes

A genome is a compact recipe that expresses into a graph. Both of GET's are indirect encodings: small objects whose local changes produce structured changes in the resulting graph, which explores far better than flipping bits in an edge list.

edge-editSDA
IsA script of edit operationsA finite-state machine
Starts fromA base graph you supply (or an empty one)Nothing
Good atRefining a network you already have Discovering structure from scratch
Configgene_length, operation weights num_states, max_resp_len, init_state
One mutation isRerolling one gene Redrawing one transition, one response, or the initial character
Crossover isTwo-point over genesTwo-point over states
They stack. A run returns edge triples and an edge-edit run starts from a graph, so you can evolve a topology with SDA and refine it with edge-edit, no new data format in between. See supplying a base graph.

Both satisfy one trait, and four parts of it are load-bearing:

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;
}

Edge-edit: A Script Over a Base Graph

A list of encoded operations, applied in order to a clone of the base graph. It is a script, not a graph: the same genome against a different base graph gives a different result, which is what makes it good at refinement.

Each gene is one 64-bit integer: an opcode in the low 4 bits and a 32-bit payload above it, decoded mixed-radix into four vertex parameters using the node count n as the base: payload % n, /n % n, /n² % n, /n³ % n. Nothing is validated; an impossible edit is a no-op.

The Nine Operations

OperationDoes
Add / Delete Add or remove one edge copy between v1 and v2.
Toggle Absent → add. At the cap → remove. Otherwise v3's parity decides.
LocalAdd / LocalDelete / LocalToggle The same three, but the far endpoint is reached by a two-hop walk from v1, to neighbour v2, then to its neighbour v3. A walk that returns to the start or passes a degree-1 node is rejected.
Hop Move an edge to the two-hop endpoint, dropping the one to the intermediate neighbour.
Swap A 2-opt rewire. Two non-adjacent vertices of degree > 2 and one neighbour each: cut both edges and cross-connect, provided all four vertices are distinct and neither would-be edge exists. Needs at least 4 nodes.
Null A deliberate no-op. Weight it to 0 to make every gene do something.

All of them are no-ops when their preconditions fail: nothing errors and nothing is retried. You choose the mix with relative weights that need not sum to anything; 0.0 disables an operation and at least one must be positive.

SDA: An Automaton That Writes a Graph

A Self-Driving Automaton is a small finite-state machine that reads back its own output as the tape driving it. It holds an init_char, a transition table ([state][char] → next state) and a response table ([state][char] → characters to emit).

Expression emits one character per upper-triangle vertex pair, and each character's raw value is that edge's weight. There is no decoding step. Output length is n(n-1)/2, in the same row-major order get_edge_list uses, so output[0] is init_char and sets edge (0,1). Every response is at least one character long, so the tape always grows and the run always terminates.

the tape: it is both the output and the input 102 110 init_char read head state 2, reading '2' → go to state 0, emit "1 1" the same tape, read as edges (0,1) = 1 (0,2) = 0 (0,3) = 2 (1,2) = 1 (1,3) = 1 (2,3) = 0 Four nodes need 4×3/2 = 6 characters. The automaton stops the moment it has them. Weight 2 on (0,3) means two parallel edges, legal here because the cap is at least 2. Every character is a legal weight by construction, so nothing is ever clamped.
The automaton writes characters; the characters are edge weights, in get_edge_list order.
The alphabet is derived, not configured. num_chars = max_edge_multiplicity + 1, so the characters are exactly 0..=cap and every one is a legal weight the graph will never clamp. There is no num_chars key: an alphabet larger than the cap would bias graphs toward it, and a smaller one would leave the upper weights unreachable. This is also why validation requires 1 ≤ cap ≤ 255.

init_state is configuration rather than genome data (mutation and crossover never touch it) and must be < num_states.

Adding a Third Representation

Implement Genome, add one config variant, one startup function and one dispatch arm. Evolvers are untouched. Recipe on Add a Genome.