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.

AS-MEASURED epi_spread → 1470 nodes infected · direction = Maximize Fitness::evaluate → 1470.0 common::express_and_score express the batch in parallel · score the batch · reject NaN ORIENT: the one and only flip inward 1470.0 → −1470.0 LOWER-IS-BETTER: what the engine compares tournament ranking · elitism · replace-worst · argmin GenerationStats · EvolutionOutcome None of these know, ask, or store which way the objective ran. −1470.0 → 1470.0 the dispatch boundary ORIENT BACK: the one and only flip outward AS-MEASURED AGAIN: 1470, exactly what your function returned
Two flips per run, at the two edges. Everything between them is a plain 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.

The engine never calls a fitness method directly. 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.

The 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

ObjectiveReadsDirection
epi_spreadTotal ever-infectedMaximize
epi_lengthTimesteps to burn outMaximize
epi_prof_match RMSE between the epidemic profile and your target curve Minimize
struct_match Structural distance to a set of reference graphsMinimize

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.

Where a reference set comes from. The repository carries two standard-library scripts that are not part of the package and are not in the wheel: 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.

t = 0 t = 1 t = 2 t = 3 ISS SS patient zero RIS SS 1 newly infected RRI SS 1 newly infected RRR SS nobody infected: burnout amber = infectious · green = recovered · grey = susceptible
An outbreak on a 5-node path that failed to transmit at step 3. It occupied three infecting timesteps plus a burnout step, and infected three nodes, so 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).

The trailing zero and the burnout step catch people out. An outbreak infecting nobody beyond patient zero gives 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.

It is a biased resample, not variance reduction. It shifts expected fitness upward by an amount depending on how often a given graph fizzles, so it is not interchangeable with raising 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

KeyDefaultEffect
infection_raterequired Per-edge, per-timestep transmission probability. Validated in [0, 1].
num_epidemicsrequired 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_zerounset Pin the seed node, or leave unset for a fresh one per epidemic.
min_epidemic_length3 Re-roll threshold; 1 disables.
max_epidemic_retries5 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:

  1. The master seed feeds a generator whose output stream is the per-run seed list; run i takes draw i. So a run's seed does not depend on how many runs you asked for: requesting 50 reproduces the first 30 exactly.
  2. Within a run, the starting population is drawn, then the evolver's own seed next from the same stream.
  3. Each scoring batch derives an epidemic seed from the run seed and a batch counter.
The generator is pinned. Both strategies seed 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 re-roll breaks CRN" is the easy misreading, and it is wrong. Every graph draws from an identical pool of dice, none of it graph-specific. What differs is only which of those common draws each one stops on, and that is what a retry is. The randomness is common; the stopping rule is outcome-dependent, deliberately.

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

3 11 20 29 38 0 30 60 generation nodes infected best_fitness mean_fitness
An actual run, not a sketch: 40 nodes, population 60, edge-edit genome, 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.

The two denominators disagree on purpose. 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.

Which routes convert, and the one that does not. The outward conversion lives in the dispatch layer, not at the language boundary, so both Python routes, the 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.

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.

Keep hot objectives native. A per-individual Python objective can be hundreds of times slower wall-clock than parallel Rust; batched, only the speed of the Python body remains.

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.