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.
| Route | You write | You edit GET | Selectable from a config file |
|---|---|---|---|
| 1: Python | a Python function | no | yes, as type = "python" |
| 2: Rust, as a library | a Rust type in your own crate | no | no; you hand it to the evolver directly |
| 3: Rust, inside GET | a 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. |
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
- Registration is refused if the config did not select
python. Silently accepting a callable that will never be consulted is indistinguishable from success, so it errors instead. - Use generational, not steady-state. Steady-state scores two children per mating event, so a Python objective pays one crossing per event instead of per generation. See Variation & Selection.
- Replicates run sequentially under a Python objective, chosen by the engine rather than
by you:
nconcurrent runs would benthreads contending for one lock. - Your own randomness is not seeded by GET. If your objective is stochastic, seed it yourself from something deterministic or you lose reproducibility.
- Return the right length. A mismatched list is an error, not a silent truncation, but returning scores in the wrong order is neither, and will quietly evolve nonsense.
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.
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
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.
| Step | Where | What |
|---|---|---|
| 1 | get/src/fitness.rs:393 |
implement the trait, and validate the objective's inputs in its constructor |
| 2 | get/src/config.rs:329, :347 and :861 |
the config variant, its type name, and its validation arm |
| 3 | get/src/dispatch.rs:133 |
the arm turning the variant into a boxed objective |
| 4 | get/src/py_config.rs:326 and :499 |
the Python variant and its attribute paths, optional |
| 5 | config.example.toml:92 |
an example block, if the objective ships |
| 6 | get/src/dispatch.rs:871 |
a helper returning the objective's [fitness] block, skippable; see
below |
| 7 | get/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
}
}
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.
- It may not touch the filesystem.
Config::validatedoes no I/O, which is whyepi_prof_matchtakes its target curve inline rather than as a path. An objective that must name a file cannot check the file here, because everything about its contents becomes step 3's problem. - It may constrain fields outside
[fitness].struct_matchrequires a top-levelmax_edge_multiplicityof 1, because its statistics count neighbours rather than summing weights, and it rejects the whole config if that is not so.
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.
- It is where anything needing the filesystem finally happens, since step 2 could not. An unreadable folder or an empty reference set is reported from here.
- Anything expensive and immutable should be built once and shared behind an
Arcrather than rebuilt per replicate; seeGraphEvolver::struct_match_reference. What a fresh objective per replicate buys is isolation of per-run state; an objective with none has nothing to keep apart, and rebuilding its data is pure waste.
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:
| Helper | What it does | Write 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.
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
- Return values in your own units and sign. Nothing is normalised for you, and nothing should be pre-negated.
- Declare
directionhonestly. A negated value and aMaximizedeclaration cancel into a run that optimises backwards, and looks exactly like one that simply is not converging. - Never return
NaN. Use+infunder minimize to mean "invalid, never select this". - Keep it deterministic given its inputs, or seed your own randomness.
- Remember it will be called from many threads at once, so it must be safe to share.