Use Rust
Rust: As a Library
Depend on the crate, write your objective as a Rust type, and drive an evolver directly. No config file and no Python anywhere in the loop. You assemble the run yourself, which is what makes a native objective fast and an embedded GET possible.
cargo add graph-evolution-tool yet: it cannot currently install this package.
Add the Dependency
# Cargo.toml
[dependencies]
graph-evolution-tool = { git = "https://github.com/md12ol/GraphEvolutionTool", tag = "v0.9.0" }
The tag selects version 0.9.0. For published results, pin a reviewed commit
rather than a moving branch and commit Cargo.lock. A local checkout can instead use
graph-evolution-tool = { path = "../GraphEvolutionTool/get" } during development.
graph-evolution-tool; the crate you use is
get. The registry name get is unavailable, so the future package name
differs from the import/crate name, like pip install scikit-learn and
import sklearn.
Every path below is use get::… and that does not change.
A source pin and lockfile are not a complete scientific record. Also preserve the run code, configuration, base/reference graphs, custom objective code, and relevant environment details. Pinning source prevents dependency drift; it does not preserve inputs outside Cargo.
Your Objective Is a Type
Implement Fitness. Score the graph in your own units and in the natural direction:
the engine converts, so do not pre-negate for a maximizing objective.
use get::fitness::{Direction, Fitness};
use get::graph::Graph;
/// How many nodes sit at exactly `target_degree`. Larger is better.
struct Regularity {
target_degree: usize,
}
impl Fitness for Regularity {
fn evaluate(&self, graph: &Graph) -> f64 {
let mut at_target = 0;
for node in 0..graph.num_nodes {
if graph.neighbor_count(node) == self.target_degree {
at_target += 1;
}
}
at_target as f64
}
fn direction(&self) -> Direction {
Direction::Maximize
}
}
Assembling a Run, in Four Parts
There is no config document on this route: you build each piece and hand it over. The parts are the population, the genome's run configuration, what the engine owns, and the strategy's own settings.
use get::evolver::{Evolver, GenerationalContext, GenerationalEvolver, SharedEvolutionContext};
use get::evolver::common::{Crossover, Selection};
use get::evolver::scope::Scope;
use get::genomes::{Genome, SdaContext, SdaGenome, SdaMutation};
// 1. The population.
let mut population = Vec::with_capacity(POPULATION_SIZE);
for _ in 0..POPULATION_SIZE {
population.push(
SdaGenome::random_with_edge_multiplicity_cap(
NUM_STATES,
MAX_EDGE_MULTIPLICITY,
4, // max_resp_len
&mut rng,
)
.expect("dimensions are within the genome's storage limits"),
);
}
// 2. How a genome becomes a graph. Run configuration, never evolved state --
// `mutate` and `crossover` never see it.
let genome_context = SdaContext {
num_nodes: NUM_NODES,
init_state: 0,
max_edge_multiplicity: MAX_EDGE_MULTIPLICITY,
init_char_mutation_rate: 0.1,
transition_vs_response_rate: 0.5,
mutation: SdaMutation::RedrawOne,
};
// 3. What the engine owns: the variation rates and cap, the scope bred from,
// who is selected, and which crossover operator runs. Never the strategy.
let shared = SharedEvolutionContext {
genome_context,
crossover_rate: 0.8,
mutation_rate: 0.9,
max_mutations: 3,
selection: Selection::Tournament { tournament_size: 7 },
scope: Scope::Global,
crossover: Crossover::TwoPoint,
};
// 4. The strategy's own configuration, and the run.
let strategy = GenerationalContext { num_generations: 200, elite_count: 1 };
let fitness = Regularity { target_degree: 4 };
let mut evolver = GenerationalEvolver::new(shared, strategy, population);
let outcome = evolver.run(&fitness, seed);
The Two Strategies
| Generational | Steady-state | |
|---|---|---|
| Evolver | GenerationalEvolver | SteadyStateEvolver |
| Context | GenerationalContext | SteadyStateContext |
| Stops after | num_generations | num_mating_events |
| Its own knob | elite_count, how many survive intact | replacement, who the children overwrite |
Both take the same SharedEvolutionContext, and both are generic over the genome, so
swapping SdaGenome for EdgeEditGenome changes the population and the
context you build, and nothing else.
Reading the Outcome
// `best_fitness_engine` is lower-is-better, whatever your objective computed.
// `direction` is how you undo that, and the negation is its own inverse.
let best = outcome.direction.orient(outcome.best_fitness_engine);
println!("best fitness = {best}");
println!("edges = {}", outcome.best_graph.get_edge_list().len());
println!("genome = {}", outcome.best_genome.print());
for row in &outcome.history {
println!(
"{} {} {}",
row.iteration,
outcome.direction.orient(row.best_fitness),
outcome.direction.orient(row.mean_fitness),
);
}
This is the one route that hands you lower-is-better numbers. The outward conversion
lives in the dispatch layer, and driving an Evolver yourself goes underneath it,
so best_fitness_engine and every history row are lower-is-better here,
even for a Maximize objective. The field is named _engine precisely so
this is not something you discover from a wrong plot.
The two Python routes and the get-run CLI all go
through dispatch and convert for you. std_dev and ci_95 are never
converted on any route: a spread is identical under negation.
Seeding and Replicates
You own the loop, so replicates are yours to run. Derive each seed from one master value so the
random stream can be repeated, and keep the pair (master seed, index). Reproducing the
experiment also requires the same source, run assembly, objective code, data, and environment.
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
// Build the generator ONCE, outside the loop. Seeding it per iteration would
// reset the stream and hand every replicate the same seed.
let mut master = ChaCha8Rng::seed_from_u64(seed);
for run_index in 0..n_runs {
let run_seed: u64 = master.random();
let outcome = one_run(run_seed);
println!("run {run_index} used seed {run_seed}");
}
That is the same policy GET's own dispatch uses for run's replicates: one generator
seeded from the master, drawn once per run, so a run's seed does not depend on how many runs you
asked for. Deriving from master + i or a hash would do as well;
master ^ i would not, because nearby masters collide across run indices.
Complete Examples
| Example | Shows |
|---|---|
library_route.rs | The whole route: a custom Fitness, SDA genome, generational strategy, replicates and output files. |
edge_edit_generational.rs | Edge-edit genome under the generational strategy. |
edge_edit_steady_state.rs | The same genome under steady-state, including replacement. |
sda_steady_state.rs | SDA under steady-state. |
These live in the crate's own repository rather than in the published package, so read them at get/examples; from a clone of GET they run with:
cargo run -p graph-evolution-tool --features cli --example library_route
Adding to GET Itself
Everything above uses GET from outside. If you want a new genome, strategy, selection scheme or variation operator inside the crate, selectable by name from a config file and available to every route, that is a different job with its own guides: see Extension Points.