Skip to content

Tutorial 3: Writing an engine-hosted Algorithm subclass

Every algorithm class in sezgi.builtins (GeneticAlgorithm, DifferentialEvolution, ...) is a thin skin over a Rust component preset. sezgi.Algorithm is the other side of that same door: subclass it, write generate(self, pop, ctx) in Python, and the Rust engine calls your method once per generation — population initialization, boundary repair, replacement, and termination all still happen Rust-side, exactly like a built-in algorithm. This is different from sezgi.AskTellAlgorithm (covered in the README's ask/tell section), which drives its own setup()/step() loop entirely in Python: an Algorithm subclass's hooks run inside the engine's own generation loop.

generate(self, pop, ctx) — the one required hook

import sezgi


class SimpleMutation(sezgi.Algorithm):
    """Every generation: perturb every current individual by an
    independent uniform step in [-step, step] per coordinate."""

    def __init__(self, step=0.5):
        self.step = step

    def generate(self, pop, ctx):
        return [[xi + (ctx.rng.next_f64() - 0.5) * 2 * self.step for xi in x]
                for x in pop.individuals]


result = SimpleMutation(step=0.5).run(sezgi.bbob(1, 5, 1), budget=1500, seed=3, pop_size=20)
print(f"evals_used={result.evals_used} best_f={result.best_f:.6g} gap={result.gap:.6g}")

evals_used=1500 best_f=-125.926 gap=0.0235024

pop.individuals is a plain Python list, one entry per genotype (bare list[float] here, since bbob's space is a single Float block — Tutorial 6 covers the multi-block/tuple case). pop.fitness is a numpy 1-D float64 array, parallel to pop.individuals — unused above, but essential for anything that needs to compare individuals (Tutorial 4's select/vary split does this internally). generate's return value is the SAME bare/tuple convention: an iterable of x-values, one generation's worth of offspring — the count is entirely this method's own choice, not checked against pop's size.

ctx.rng — the determinism knob

ctx is a handle over the engine's own per-call context: ctx.iteration (the current generation count), ctx.space (one descriptor dict per space block — {"kind": "float", "lo": ..., "hi": ..., "n": ...} for a Float block), and ctx.rng — a handle over the engine's OWN seeded stream for this stage/call. ctx.rng.next_f64() draws a uniform float in [0, 1); ctx.rng.next_below(n) draws a uniform integer in [0, n). Drawing from ctx.rng (rather than, say, Python's own random module) is what keeps a run() deterministic under a fixed seed — the same seed always produces the same sequence of ctx.rng draws, and therefore the same run, exactly like every built-in Rust component drawing from its own stage stream:

import sezgi


class SimpleMutation(sezgi.Algorithm):
    def __init__(self, step=0.5):
        self.step = step

    def generate(self, pop, ctx):
        return [[xi + (ctx.rng.next_f64() - 0.5) * 2 * self.step for xi in x]
                for x in pop.individuals]


r1 = SimpleMutation(step=0.5).run(sezgi.bbob(1, 5, 1), budget=1500, seed=3, pop_size=20)
r2 = SimpleMutation(step=0.5).run(sezgi.bbob(1, 5, 1), budget=1500, seed=3, pop_size=20)
print(f"same seed twice: best_x equal = {r1.best_x == r2.best_x}, best_f equal = {r1.best_f == r2.best_f}")

same seed twice: best_x equal = True, best_f equal = True

initialize(self, n, ctx) — optional population seeding

By default, an Algorithm subclass that does not override initialize never has it called at all: the engine's own Rust init/uniform initializer seeds the population instead, with no Python call involved. Overriding initialize replaces that with a Python-authored seeding strategy — same signature convention as generate's return value (an iterable of n x-values). Below, CornerStart seeds every individual near one corner of the search box instead of uniformly across it, demonstrating that this hook genuinely controls where the search begins:

import sezgi


class CornerStart(sezgi.Algorithm):
    def __init__(self, step=0.5):
        self.step = step

    def initialize(self, n, ctx):
        block = ctx.space[0]  # this problem's one Float block
        dim = block["n"]
        lo, hi = block["lo"], block["hi"]
        near = lo + 0.05 * (hi - lo)     # 5% in from the lower bound
        span = 0.1 * (hi - lo)           # a narrow band near that corner
        return [[near + ctx.rng.next_f64() * span for _ in range(dim)]
                for _ in range(n)]

    def generate(self, pop, ctx):
        return [[xi + (ctx.rng.next_f64() - 0.5) * 2 * self.step for xi in x]
                for x in pop.individuals]


result = CornerStart(step=0.5).run(sezgi.bbob(1, 5, 1), budget=1500, seed=3, pop_size=20)
print(f"evals_used={result.evals_used} best_f={result.best_f:.6g} gap={result.gap:.6g}")
print(f"best_x = {[round(v, 4) for v in result.best_x]}")

evals_used=1500 best_f=-125.914 gap=0.0353014 best_x = [2.7033, 1.9419, 3.4343, 2.6016, -3.8618]

Whether initialize was actually overridden is decided by comparing the resolved method against Algorithm.initialize itself (function identity, not a sentinel return value) — so a subclass that legitimately wants initialize to do something unusual (including returning an empty sequence) is never mistaken for "did not override it".

Spec persistence: the one honest gap

An engine-hosted Python algorithm is a live callback registered into the Rust engine's per-call registry — it is process-local. Unlike a purely Rust-component spec (sezgi.presets.*, used by sezgi.solve() and every benchmarking/spec-file path), there is no .toml/.json wire format a Python generate/initialize callback can be serialized into. A sezgi.Algorithm subclass instance cannot be saved to and reloaded from a spec file the way a preset-backed run can — an honest degrade, not a gap this milestone silently papers over.

Next