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.

Source only in 0.9.0. GET is not published to crates.io. Use the pinned source tag below, or choose the Python route. Do not use 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.

The package is 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

GenerationalSteady-state
EvolverGenerationalEvolverSteadyStateEvolver
ContextGenerationalContextSteadyStateContext
Stops afternum_generationsnum_mating_events
Its own knobelite_count, how many survive intactreplacement, 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

ExampleShows
library_route.rsThe whole route: a custom Fitness, SDA genome, generational strategy, replicates and output files.
edge_edit_generational.rsEdge-edit genome under the generational strategy.
edge_edit_steady_state.rsThe same genome under steady-state, including replacement.
sda_steady_state.rsSDA 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.