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.
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.| Stage | Does |
|---|---|
| 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. → |
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.
network_size × network_size however sparse the graph is, which is why
memory scales with the square of the node count.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-edit | SDA | |
|---|---|---|
| Is | A script of edit operations | A finite-state machine |
| Starts from | A base graph you supply (or an empty one) | Nothing |
| Good at | Refining a network you already have | Discovering structure from scratch |
| Config | gene_length, operation weights |
num_states, max_resp_len, init_state |
| One mutation is | Rerolling one gene | Redrawing one transition, one response, or the initial character |
| Crossover is | Two-point over genes | Two-point over states |
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;
}
crossoverrecombines in place, leaving one child in each parent, and both are kept.mutateapplies exactly one mutation per call. The engine calls it in a loop; the genome does not get a vote on how many times.printlets a non-generic entry point record which individual won without knowing its representation.Contextcarries run-level configuration and is the authority on graph size and edge cap. It is shared by reference across every worker, which is why the bounds are there.
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
| Operation | Does |
|---|---|
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.
get_edge_list order.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.