Learn
Fitness & Output
A fitness function turns a graph into one number. This page is that number's life: which direction is better, the epidemic that produces it, the seeding that makes it reproducible, and the log it ends up in.
The Contract
pub trait Fitness: Send + Sync {
fn evaluate(&self, graph: &Graph) -> f64;
fn direction(&self) -> Direction { Direction::Minimize }
fn evaluate_batch(&self, graphs: &[Graph]) -> Vec<f64> { /* par_iter over evaluate */ }
}
evaluate returns a score as-measured, in the units and sign your objective returned. Nothing is
normalised or flipped for you. direction declares whether bigger or smaller is
better, and is a property of what the function computes, never of the run, so it is never
configurable. evaluate_batch scores a whole population at once, which is what lets a
Python objective pay one crossing per batch rather than one per
individual.
Two Number Systems, One Gate
As-measured is whatever your objective returns. Lower-is-better is what the engine compares. Selection, elitism and replacement all assume it, which is what lets them work without ever asking what the objective was. The conversion is a negation, and the whole design is about doing it in exactly two places.
f64 comparison with no direction in sight, which is why a missed conversion is
impossible rather than merely unlikely.common::express_and_score(batch, context, fitness) -> (Vec<Graph>, Vec<f64>)
This one function expresses every genome in parallel, scores the batch, applies the direction
conversion and rejects NaN. It is the only place the whole population is scored, so
it is the only place "exactly once" can be guaranteed rather than remembered.
express_and_score is the
sole path from genomes to fitnesses, in both strategies. A direct call would bypass orientation
and the NaN gate, and neither failure announces itself.
NaN is rejected because under Maximize the value is negated, and
-NaN sorts below -inf, making it the best individual in
every tournament it enters, filling the population with whatever genome produced it and leaving a
run that looks converged. ±inf is allowed deliberately: +inf under
Minimize is the sanctioned way to say this individual is invalid.
NaN gate is an assertion, so it aborts rather than raising. From Python it
surfaces as a pyo3_runtime.PanicException naming the batch index that produced it,
not a ValueError you would normally catch. Treat it as a bug in your objective, which
it is: the message names the usual causes (a division by a possibly-zero count,
0.0/0.0, inf - inf). Note the two infinities are the opposite case and
are let through, so a run reporting -inf under Minimize is your objective
saying something is perfect, not something failing.
The Four Built-in Objectives
| Objective | Reads | Direction |
|---|---|---|
epi_spread | Total ever-infected | Maximize |
epi_length | Timesteps to burn out | Maximize |
epi_prof_match |
RMSE between the epidemic profile and your target curve | Minimize |
struct_match |
Structural distance to a set of reference graphs | Minimize |
The first three all read one SIR simulation and are stochastic.
struct_match shares none of that vocabulary: it compares three structural statistics
of a graph (degree histogram, clustering-coefficient histogram, Laplacian spectrum) against the
same statistics over a reference set loaded with load_reference_graphs, and it is
deterministic, so a second call on the same graph gives the same score. It
requires max_edge_multiplicity = 1 and refuses a multigraph rather than
coercing one, because the statistics are defined on simple graphs.
tools/tudataset_to_get.py
converts a TUDataset collection into the folder
of edge files load_reference_graphs reads, and
tools/graph_to_png.py
draws any GET edge file as a PNG. Both are documented in
tools/README.md.
epi_prof_match's length rule is fixed by the target, and it is asymmetric.
Iterate 0 .. target.len(), treat a shorter run's missing values as 0,
ignore a longer run's surplus, and divide by target.len() always. So a run
that burns out early is penalised by the whole remaining target while one that outlasts it is not
penalised at all: this objective rewards matching or exceeding the target's tail, not
matching it exactly.
The SIR Model
Every node is Susceptible, Infectious or Recovered, and only ever moves
forwards. GET's infectious period is exactly one timestep: a node infected during step
t spends step t+1 infectious, transmitting to each still-susceptible
neighbour with probability infection_rate per edge, then recovers forever. A
single patient_zero seeds the outbreak, which runs until no infectious nodes remain
No step cap is needed, because a node only ever moves S → I → R and so can be infected at most
once, which bounds the run by the node count.
length = 3, spread = 3, and
profile = [1, 1, 1, 0]. Note that profile always carries one more
element than length, because of the terminating zero.
One simulation returns spread (how many nodes were ever infected, counting
patient zero), length (timesteps occupied, including the final burnout step),
and profile (newly infected per timestep, starting at 1 and ending in a
terminating zero).
length = 1, spread = 1,
profile = [1, 0]. A 6-node path burning through at infection_rate = 1.0
gives length = 6, spread = 6,
profile = [1, 1, 1, 1, 1, 1, 0]. A target_profile you compare against
should include that trailing zero.
Short Epidemics Are Re-rolled
An outbreak shorter than min_epidemic_length is discarded and re-simulated, up to
max_epidemic_retries attempts; whatever the final attempt produces is kept
regardless. It exists because a fizzled outbreak reports the dice, not the graph. Without it a
large share of evaluations return near-nothing and selection chases noise.
num_epidemics. Accepted deliberately, which is why both values are exposed
rather than hardcoded. To switch it off set
min_epidemic_length = 1; 0 is a validation error, not an off switch.
SIR Parameters
| Key | Default | Effect |
|---|---|---|
infection_rate | required | Per-edge, per-timestep transmission probability. Validated in
[0, 1]. |
num_epidemics | required | Outbreaks averaged per evaluation. A single draw is very noisy, so this is not a tuning nicety, and it dominates run time, since the simulations run sequentially within one evaluation. |
patient_zero | unset | Pin the seed node, or leave unset for a fresh one per epidemic. |
min_epidemic_length | 3 |
Re-roll threshold; 1 disables. |
max_epidemic_retries | 5 |
Attempts before keeping whatever came out. |
Seeds & Reproducibility
One seed for the whole experiment, passed to run rather than set in the
config: the seed describes this invocation, the config describes the experiment.
Everything derives from it, in layers, each drawing from the previous rather than reusing
it:
- The master seed feeds a generator whose output stream is the per-run seed list; run
itakes drawi. So a run's seed does not depend on how many runs you asked for: requesting 50 reproduces the first 30 exactly. - Within a run, the starting population is drawn, then the evolver's own seed next from the same stream.
- Each scoring batch derives an epidemic seed from the run seed and a batch counter.
ChaCha8Rng, not the library
default, whose algorithm may change between releases, which would defeat the entire purpose of a
seed argument.
The Epidemic Dice
Two requirements pull in opposite directions. Within one evaluation every population member must face the same draws: common random numbers, which is what makes fitness differences reflect the graph rather than the dice. Across evaluations the same network must not keep getting the same epidemic, or the run optimises against one frozen sample of the disease.
So the objective holds a run seed plus an atomic evaluation counter: each batch increments
it once and derives that batch's epidemic seed from the pair. Within the batch, epidemic
i attempt a takes draw
i × max_epidemic_retries + a, position-indexed, which is what keeps the
re-roll compatible with common random numbers and makes scoring
order-independent across parallel workers.
The counter is per-run state: each run owns its own objective instance. Sharing one across concurrent runs would let thread scheduling decide which run sees which seed, and reproducibility would evaporate only under load.
The seed repeats GET's random stream for fixed code and inputs; it is not a complete experiment record. Preserve the exact commit/version, config, master seed and replicate index, base and reference graphs, custom objective and run code, dependency versions, and relevant environment. A Python objective with its own randomness needs its own recorded seeding policy. Different core counts are intended to preserve GET's stream, but record hardware and software context rather than promising machine-independent identity.
Logs & Results
epi_spread, seed 20260821. Best fitness climbs from 10.4 to 35.0 of 40
nodes; the mean tracks it a little below, which is what a healthy run looks like. Regenerate with
tools/make_doc_figures.py.
One row per logged iteration: every generation for
generational, every population_size events
for steady-state. Both log the starting population as
iteration 0, so the two share an axis.
iteration,best_fitness,mean_fitness,std_dev,ci_95,seed,run_index
seed and run_index repeat on every row so that replicate logs can be
concatenated into one file and still be separated, with no naming convention and no
parse-the-filename step in whatever plots them.
std_dev describes the population as
the complete thing it is, dividing by n. ci_95 is a sampling
statistic: 1.96 · s / √n with the sample deviation, dividing by
n-1. A single individual has std_dev = 0 and ci_95 = 0,
never NaN.
ci_95 is a within-population band, per iteration: how tightly this
generation is clustered around its mean. It is not the band on a published convergence
plot, which is mean best-fitness across replicates and is computed when their logs are
aggregated. Mixing them up produces a plot that looks right and answers the wrong question.
Units, and the Best Individual
A run hands back the winning genome printed, the network it expressed as
(u, v, multiplicity) triples with u < v, and its fitness. It is the
best of the final population rather than a running best,
Design Notes has why that is the honest number.
The dispatch layer converts best_fitness and mean_fitness back into
as-measured units and sign on the way out. std_dev and ci_95 are not
converted, and correctly so: a spread is identical under negation. So an epi_spread
log reads in infected nodes and goes up; an epi_prof_match log reads as an
RMSE and goes down.
get-run CLI and
run_many_from_toml all hand you numbers already in your objective's units. The
exception is driving an Evolver directly: that
returns an EvolutionOutcome carrying best_fitness_engine, which is
lower-is-better, plus the direction to undo it:
direction.orient(outcome.best_fitness_engine) is the same negation, and it is its own
inverse.
results = evolver.run(seed=20260812, n_runs=30, max_cores=8)
best = max(results, key=lambda r: r.best_fitness) # epi_spread maximizes
print(best.best_fitness, best.best_edges, best.best_genome_repr)
best.save_logs("convergence.csv") # the seven columns above
best.save_results("results.txt") # the best individual, as a loadable edge file
best.save_config(".") # writes one shared config.toml
The config TOML and logged seed are two parts of provenance, not the whole record. Also preserve the exact commit/version, run and objective code, base/reference graphs, identifier mapping, dependencies, and relevant environment.
save_results writes a loadable edge file, not a report. The fitness, the
genome and the node count go in # comment lines above ordinary
u,v,weight rows, the same format set_base_graph_from_file reads. So the
winner of one run feeds straight into the next as a base graph, which is what makes
stacking SDA into edge-edit a two-line operation.
Interpreting Runs Scientifically
The max(...) example above is useful when you need one candidate network. It is not
the right summary of evidence across replicates: reporting only the most favorable run introduces
selection bias.
- Report the distribution across independent replicates, not only the winner.
- Compare against a declared baseline or null procedure using the same evaluation budget.
- For stochastic objectives, re-evaluate selected networks on fresh or held-out random draws; the simulation samples used during evolution are training evidence.
- Treat a flattening convergence curve as a search diagnostic, not proof of mathematical or biological convergence.
- Separate “GET optimized the stated objective” from any claim that the evolved network is biologically plausible or scientifically explanatory.
Writing Your Own Objective
Two routes, and neither is a fallback for the other. See Add an
Objective for both. Python:
set_fitness_function(callable, direction), taking the whole batch at once, with no
Rust and no compilation. Rust: depend on the crate, impl Fitness, drive an
evolver directly.
A user-supplied objective deliberately gets no [fitness] variant. A string-keyed
registry would move validation out of the parser, which is the exact failure the one-validator
design prevents: nothing user-supplied is ever deserialized, so there is nothing new to
validate.