Contribute

Add an Objective

Scoring graphs by something other than an epidemic. Three routes, in increasing order of effort, named for the way you will be running GET: a Python callable, a Rust type in your own crate, or a native objective inside GET itself.

RouteYou writeYou edit GETSelectable from a config file
1: Pythona Python functionno yes, as type = "python"
2: Rust, as a librarya Rust type in your own crateno no; you hand it to the evolver directly
3: Rust, inside GETa Rust type in get/src/yes yes, by name

The middle column is the whole decision. Route 2 buys you native speed without a fork and without a config variant; what it cannot give you is a name a config.toml can select, because the config schema lives inside the crate. Route 3 is the only one that produces an objective someone else can run from a document, and it is the only one with steps to follow.

What the User Ends Up Writing

A GET run is configured by a TOML document. See the Python: TOML File for its full shape, and Python: Config Objects for how to run one. The objective is the [fitness] section. A Python callable is selected by name, and registered separately through the evolver:

[fitness]
type = "python"

An objective added inside GET (route 3) is selected the same way its own variant is named:

[fitness]
type           = "my_objective"
some_parameter = 0.5

You never write that string in the code. It is derived from your FitnessConfig variant's name, converted to snake_case, the same rule every other section follows. The keys under it are your struct's field names.

Route 1 is the exception worth knowing: type = "python" names no objective of its own. It says "a callable will be registered", and a run that reaches scoring without one is rejected rather than silently scoring nothing. Route 2 appears in no config document at all: a library caller hands the objective over directly, so there is nothing for [fitness] to name.

Route 1, Python: A Registered Callable

Set [fitness] type = "python" in the config and register your function. No Rust, no compilation.

import get
import numpy as np

config = get.Config(
    evolution       = get.EvolutionConfig.Generational(num_generations=200),
    population_size = 100,
    network_size    = 60,
    crossover_rate  = 0.9,
    mutation_rate   = 0.2,
    scope           = get.ScopeConfig.Global(),
    selection       = get.SelectionConfig.Tournament(tournament_size=5),
    genome          = get.GenomeConfig.EdgeEdit(gene_length=128),
    fitness         = get.FitnessConfig.Python(),      # note the empty parentheses
)

def total_weight(batch):
    """Score a whole population at once. One call, one list back."""
    return [float(sum(w for (_u, _v, w) in edges)) for (_n, edges) in batch]

evolver = get.GraphEvolver.from_config(config)
evolver.set_fitness_function(total_weight, "maximize")
result = evolver.run(20260812)

The Contract

Input The whole population, as a sequence of (num_nodes, edge_list) pairs. Each edge list is (u, v, multiplicity) triples with u < v; pairs with no edge are absent.
Output One number per input, in the same order. Position i of your result is the score of individual i.
Direction Declared at registration: "minimize" or "maximize", case-insensitive. Nothing can infer it from your function, and this is the only place it can be said.
What you may return Any real number. To mark an individual invalid use the infinity that sorts worst for your direction: +inf under minimize, -inf under maximize; the other one wins every tournament. NaN stops the run; it is not skipped or scored as a loss. Watch for a zero denominator, 0.0/0.0 and inf - inf: an empty graph puts all three within reach.
It must be batched, and this is not a style preference. A per-individual callback serializes every call behind the interpreter lock, losing all the parallelism GET's expression stage just bought you, and paying lock contention on top, and running Python's arithmetic instead of Rust's. The measured gap is orders of magnitude. Batched, only the speed of your function body remains.

Vectorising It Properly

import numpy as np

def clustering_penalty(batch):
    """Prefer graphs whose degree distribution is even. Vectorized per graph."""
    out = np.empty(len(batch))
    for i, (n, edges) in enumerate(batch):
        deg = np.zeros(n)
        for (u, v, w) in edges:
            deg[u] += w
            deg[v] += w
        out[i] = float(deg.std())        # lower is more even
    return out                           # a numpy array is fine

evolver.set_fitness_function(clustering_penalty, "minimize")

Things That Will Bite You

Not part of the v0.9 release. Both Rust routes below need GET as a crate, and GET does not yet publish to crates.io, so Route 1, a registered Python callable, is the supported way to add an objective for now. This note is removed once the crate is obtainable.

Route 2, Rust: As a Library

If your objective is hot enough to matter and you do not want to fork GET, depend on it as a library. This is the same route as Rust: As a Library, and that page is how you drive a run, this section is how you score one. It never touches dispatch or the config schema:

use get::fitness::{Direction, Fitness};
use get::graph::Graph;

struct EdgeBudget { target: f64 }

impl Fitness for EdgeBudget {
    fn evaluate(&self, graph: &Graph) -> f64 {
        let mut total = 0.0;
        for (_u, _v, w) in graph.get_edge_list() {
            total += w as f64;
        }
        (total - self.target).abs()
    }

    fn direction(&self) -> Direction { Direction::Minimize }
}

Then build a population, a genome context and an evolver yourself and call run with your objective. The evolver's run is generic over the objective, so a caller holding a concrete type instantiates the evolver with it directly: no arm, no config variant, no boxing.

Numbers you read from the outcome are as lower-is-better: lower is better, whatever your direction said. That is the stated cost of converting only at the two edges of the system, and the direction carried on the outcome is what you convert with. See Fitness & Output.

Assembling contexts and populations by hand is genuinely awkward today; that is a known rough edge of this route rather than a hint that you are doing it wrong.

Route 3, Rust: Inside GET Itself

Not part of the v0.9 release. This route means editing GET's own source, so it needs the crate, and GET does not yet publish to crates.io, so Route 1, a registered Python callable, is the supported way to add an objective for now. This note is removed once the crate is obtainable.

This is the route that gives an objective a name. After it, type = "my_objective" works in any config document, on every route, with no Rust at the call site. It is also the only one of the three with a chain, and every step is marked in the source the same way every other extension chain is. One command lists the whole thing:

git grep -n "ADD AN OBJECTIVE STEP" get/src config.example.toml   # 7 steps, 10 sites

Each step is named at more than one site, because a marker points forward to the next; go by the step numbers rather than the number of lines the grep prints. The line numbers below were correct when this page was written; if one has moved, the marker is the anchor that has not.

StepWhereWhat
1get/src/fitness.rs:393 implement the trait, and validate the objective's inputs in its constructor
2get/src/config.rs:329, :347 and :861 the config variant, its type name, and its validation arm
3get/src/dispatch.rs:133 the arm turning the variant into a boxed objective
4get/src/py_config.rs:326 and :499 the Python variant and its attribute paths, optional
5config.example.toml:92 an example block, if the objective ships
6get/src/dispatch.rs:871 a helper returning the objective's [fitness] block, skippable; see below
7get/src/dispatch.rs:904 the test case asserting the boxed objective reports its own direction

Five files is the floor, not the ceiling. That is what an objective assembled from config values alone needs. One that brings data of its own also touches the loader it reads through and wherever its shared state lives; struct_match touched seven for that reason.

Step 1: The Trait

Everything below assumes an objective called MyObjective with one parameter. Start with the type and its implementation, in get/src/fitness.rs:

// step 1: fitness.rs:393
pub struct MyObjective {
    threshold: f64,
}

impl MyObjective {
    /// Fallible because `threshold` can be wrong, and step 2's validation
    /// does not run on the library route.
    pub fn new(threshold: f64) -> Result<Self, &'static str> {
        if !threshold.is_finite() {
            return Err("threshold must be finite");
        }
        Ok(Self { threshold })
    }
}

impl Fitness for MyObjective {
    fn evaluate(&self, graph: &Graph) -> f64 {
        let mut over = 0;
        for node in 0..graph.num_nodes {
            if graph.neighbor_count(node) as f64 > self.threshold {
                over += 1;
            }
        }
        over as f64
    }

    fn direction(&self) -> Direction {
        Direction::Maximize
    }
}
Validate in the constructor as well as in step 2, not instead of it. Step 2's checks live in the config layer, which the library route never runs, so a guard written only there does not exist for a caller who depends on the crate. The two sites are additive. EpiProfMatch::new rejects an empty or non-finite target for exactly this reason, and StructMatch::new rejects a non-finite weight.

Step 2: The Config Side, Three Edits

All in get/src/config.rs. The variant, its name, and its constraints:

// step 2a: config.rs:329   the variant a user selects
MyObjective { threshold: f64 },

// step 2b: config.rs:347   the string `[fitness] type` is written as
FitnessConfig::MyObjective { .. } => "my_objective",

// step 2c: config.rs:861  the validation arm, if anything can be wrong
FitnessConfig::MyObjective { threshold } => {
    if !threshold.is_finite() {
        return Err(invalid("threshold", "must be finite"));
    }
    return Ok(());
}

You never write "my_objective" as a config key; the variant name converted to snake_case is what a user types. Step 2b cannot be forgotten: the match is exhaustive, so omitting it fails to compile. Steps 2a and 2c can.

Two things about that validation are not obvious.

Step 3: Dispatch

// step 3: dispatch.rs:133
FitnessConfig::MyObjective { threshold } => {
    let objective = MyObjective::new(*threshold).map_err(PyValueError::new_err)?;
    Ok(Box::new(objective))
}

Steps 2 and 3 are one change split across two files: a variant nothing constructs is dead code, and an arm for a variant that does not exist will not compile.

This arm runs once per replicate, not once per run. Two consequences:

Steps 4 and 5: Python and the Example, Both Optional

// step 4: py_config.rs:499   the Python-side variant
#[pyo3(constructor = (threshold))]
MyObjective { threshold: f64 },

// step 4: py_config.rs:499   an attribute path per validated field
"threshold" => Some("config.fitness.threshold"),

Leaving step 4 out costs nothing elsewhere: the objective is then TOML-only and a Python caller simply cannot name it. But if step 2c raised a new field name, that name needs its attribute path, or a Python caller who trips your check sees an error naming a TOML field they never wrote. The test every_validation_field_maps_to_a_python_attribute catches a missing one.

Step 5 is a commented-out block in config.example.toml:92. The example file is what a user copies from, so an objective missing from it is one most people never find, and the test suite parses and validates that file, so a block that does not parse fails the build rather than a user's afternoon.

Step 6: A Block Helper, If the Block Needs One

The one step you can skip. The test below feeds each objective a [fitness] block as a String, and two things make that string worth putting behind a function rather than writing it out:

HelperWhat it doesWrite your own when
sir_block(type_name, extra) Formats a block for type_name carrying the infection_rate and num_epidemics keys every SIR objective needs, plus whatever extra adds. your objective shares required keys with others, and the helper is what stops three cases restating them and disagreeing
struct_match_block(name) Writes a real reference set (two triangles and a four-cycle) into a fresh temporary folder, then returns a block whose reference_folder names it. your objective reads something off disk. objective() is what reads it, so a block naming a folder that does not exist fails there, not in the test
// step 6: dispatch.rs:871   beside `sir_block` and `struct_match_block`
fn my_objective_block(threshold: f64) -> String {
    format!("[fitness]\ntype = \"my_objective\"\nthreshold = {threshold}\n")
}

Needing neither? Skip it and write the block inline at step 7; there is nothing for a helper to do.

Step 7: The Test That Catches a Silent Failure

// step 7: dispatch.rs:904   one entry in the existing `cases` array
let cases = [
    (sir_block("epi_spread", ""), Direction::Maximize),
    (sir_block("epi_length", ""), Direction::Maximize),
    (
        sir_block("epi_prof_match", "target_profile = [1, 3, 7, 2]\n"),
        Direction::Minimize,
    ),
    (struct_match_block("direction"), Direction::Minimize),
    // yours, using the step 6 helper:
    (my_objective_block(3.0), Direction::Maximize),
    // or inline, if you wrote no helper. The `\n`s are the line breaks of the
    // TOML: `[fitness]`, then `type`, then the objective's own keys.
    (
        "[fitness]\ntype = \"my_objective\"\nthreshold = 3.0\n".to_string(),
        Direction::Maximize,
    ),
];

Each case is a [fitness] block, as a String, and the direction it must come back with. .to_string() on the inline form only because the array holds Strings and a literal is a &str.

each_objective_erases_to_a_box_carrying_its_own_direction asserts that your variant survives being boxed with its direction intact. Worth writing because the failure it catches is silent: Fitness::direction defaults to Minimize, so an objective whose direction is lost runs the search backwards and looks merely unconverged. Nothing panics and no number looks wrong.

Two traps in the trait itself. Do not call self.evaluate_batch from inside evaluate: the default evaluate_batch calls evaluate, so that is infinite recursion and a stack overflow at runtime rather than a compile error. And if you override evaluate_batch, remember it must produce results in input order.

Sharing One Simulation Between Objectives

If your objective needs an epidemic, read the existing pattern first: the expensive part is the simulation, so one run returns { length, spread, profile } and each of the three SIR objectives is a thin reading of it. Adding a fourth reading of the same simulation is much cheaper than adding a fourth simulator.

Checklist, Whichever Route