Project

Design Notes

Why GET is shaped the way it is: the alternatives that were rejected, and what each would have failed at. The mechanisms are on the How It Works pages; this page is the reasoning, and the non-goals.

The Organising Principle: Make Silent Failure Impossible

An evolutionary run that is subtly wrong looks exactly like one that is merely not converging. No crash, no stack trace, no obviously bad number, just a curve that flattens out somewhere it should not have. Nearly every structural decision in GET converts a silent failure into either an impossibility or a loud one.

Silent failure avoidedBy
Optimising in the wrong direction One conversion point, not a direction-aware comparator. โ†’
One NaN taking over the population Rejecting it at the same gate as the conversion.
An SDA alphabet biasing every graph Deriving it from the edge cap rather than configuring it. โ†’
The Python front end accepting what a file would reject Making Python a builder for TOML, not a second parser. โ†’
Reproducibility evaporating under load Per-run objective state, never shared across concurrent runs. โ†’
An impossible edit needing a validity check A silent, dense graph: out-of-range edits are no-ops evolution routes around, so expression cannot fail. โ†’
Two config values disagreeing One source of truth for each: population size is the population's length, graph size belongs to the genome context.

Static Generics, Not Trait Objects

The engine is statically generic over the genome, and this is forced rather than chosen. A genome cannot be a trait object: its mutation and crossover methods are generic over the random number generator, crossover takes a mutable Self, cloning requires a statically known size, and its context is an associated type that differs per representation. Any one of those rules out erasure.

Fitness has none of them, so the objective is erased, before the evolver is built. That asymmetry is the most consequential shape decision in the codebase: it collapses dispatch from strategy ร— genome ร— objective down to strategy ร— genome, and makes adding an objective free of dispatch entirely. Extension Points has what that costs each kind of addition.

One Conversion, at One Gate

Objectives return values in their own units and sign; the engine only ever minimizes. The common reconciliation, a direction-aware comparator, is rejected, because it needs the direction at every comparison site: tournament ordering, the generational argmin, the steady-state best. A missed one fails silently. Converting in the batch scorer instead puts it in the only place the whole population is scored, which is the only place "exactly once" can be guaranteed rather than remembered. The same function is the NaN gate: orientation and rejection share one door by design.

Convert at the Edges, Never in the Middle

An earlier design converted back inside the statistics and the outcome, so a number flipped twice on a short trip and every internal type had to know the direction. Now nothing in the middle converts, and three things fall out, the third being the real prize:

  1. Two conversions per run instead of one per statistics row.
  2. The statistics function loses its direction parameter entirely.
  3. The standard-deviation special case disappears. Deviation is invariant under negation, so a converting statistics function had to deliberately not convert it, an asymmetry that looked like a missed case and had to be defended by a test. When nothing converts internally, there is nothing to except.

The stated cost: a Rust embedder reading the outcome directly gets lower-is-better numbers, which is what the direction carried on the outcome is for.

Two Things Accepted as Costs, Not Solved

The epidemic re-roll is a biased resample. Discarding outbreaks shorter than a threshold shifts expected fitness upward by an amount depending on how often a given graph fizzles, so it is not interchangeable with averaging more epidemics. It is kept because a fizzled outbreak reports the dice rather than the graph, and because the historical results GET is compared against were produced with it. โ†’

Steady-state carries stale fitnesses. Incremental scoring plus a stochastic objective means the population's recorded fitnesses were computed under different dice than the children being compared against them. The alternatives, rescoring periodically or freezing the seed across a generation equivalent, each trade it for a different distortion rather than removing it. So it is accepted and documented, with the guidance to prefer generational when the comparison needs to be fair. โ†’

Smaller Decisions, Each With a Reason

Decisions whose reasoning lives nowhere else. Where a choice is explained on the page describing the mechanism, it is not repeated here.

DecisionBecause
Validation is a function, not a parse side effect The Python front end bypasses the parser entirely. Without one shared validator it would silently accept what a file rejects, and Python is the path users take.
Validation returns an error, never panics A bad config is a user mistake. A panic crossing the FFI reaches the user as an opaque exception they cannot act on.
A user objective gets no config variant A string-keyed registry would move validation out of the parser: the exact failure the single-validator design prevents. Nothing user-supplied is ever deserialized, so there is nothing new to validate.
The engine chooses the replicate mode, not the user The fitness type already determines the correct answer, so a setting would only create a way to choose wrong.
Thread pool built per call, not globally A global pool configures once per process, and this is an extension module imported once per session, so the core cap would belong to whichever call happened first.
A Python objective must be batched Per-individual callbacks serialize behind the interpreter lock, losing all the parallelism the expression stage just bought: orders of magnitude, not percentages.

Non-Goals

Stated so their absence reads as a decision rather than an oversight.

Not in GET
Multi-objective optimisation One f64 per individual. The pymoo-shaped interface is about the batched call signature, not about optimising several objectives. There is no Pareto front, no non-dominated sorting, no crowding distance.
Fixed-length runs A run ends after its configured generations or mating events. No convergence detection, no stagnation cutoff, no target-fitness early stop.
Fixed-size genomes Gene length and the SDA dimensions are set once per run. Crossover tolerates unequal lengths defensively, but nothing produces them.
No random immigrants or restarts They would require putting genome-minting capability back into the engine, which the design deliberately removes.
Fixed node count Every graph in a run has network_size nodes.
One populationNo islands, no migration.

Operator selection is built, and it is deliberately asymmetric. Crossover is chosen once under [crossover] and applies to whichever representation is running, because both genomes ship the same two-point operator through one helper. Mutation is chosen per genome, under [genome], because the two representations' mutations share no shape: a single shared enum would carry variants that are dead for one genome from its first release, and a config would accept them.

Conventions, in One Place