Extension architecture¶
How a new heuristic (Simulated Annealing #11, a refactored GA #25, ...) plugs into pymetaheuristics without touching the core problem model. The guiding rule (umbrella #12): a functional core. Heuristics are plain functions, not subclasses, and nothing is a class that merely wraps a function.
pymetaheuristics/core/
problem.py Problem, Direction what is being optimized
result.py OptimizationResult what every run returns
termination.py State, Stop, stop helpers when a run ends
evaluation.py counting() how evaluations are counted
heuristic.py Heuristic (Protocol) the contract tying it together
loop.py run() the shared iteration loop
utils/rng.py make_rng() reproducibility (re-exported by core)
1. What interface must every heuristic satisfy?¶
A heuristic is a function:
core.heuristic.Heuristic is a typing.Protocol describing exactly that,
used for type checking only. There is no base class to inherit and nothing to
register: any function with this shape is a heuristic.
problemis the only view of the domain (generate,evaluate,feasible,direction). A heuristic never imports problem-specific code.stopandrngare keyword-only and common to all heuristics.- Algorithm-specific knobs (population size, temperature schedule, operators) are extra keyword-only parameters with defaults. Defaults keep the function compatible with the Protocol.
Sketch of Simulated Annealing under this contract:
def simulated_annealing(problem, *, stop, rng=None, neighbor=swap_neighbor,
temperature=exponential_cooling(100, 0.95)):
...
return OptimizationResult(best, best_value, history=..., ...)
2. What is shared between heuristics?¶
Only data and small functions, all in core:
| Shared piece | Where |
|---|---|
| Problem model and optimization direction | core/problem.py |
| Result shape | core/result.py |
| Stop conditions | core/termination.py |
| Evaluation counting | core/evaluation.py |
Randomness (int seed or random.Random) |
utils/rng.py |
| Comparing values under a direction | core/direction.py (#21) |
| Handling infeasible solutions | core/feasibility.py (#24) |
Operators that are reusable across families (e.g. a swap neighbor used by
both a GA mutation and an SA move) live as plain functions in a shared module
(pymetaheuristics/neighborhoods.py)
and are passed in as keyword arguments. Operators never mutate their inputs.
3. What belongs to an algorithm vs the core engine?¶
There is no engine object. What differs between families (generations vs a
single trajectory vs restarts) is one iteration, so an algorithm writes two
plain functions and hands them to core.loop.run:
init(problem) -> (carry, solution, value, record)
step(problem, carry) -> (carry, solution, value, record)
return run(problem, stop=stop, init=init, step=step,
extras=lambda carry: {...}) # optional extra metadata
carry is whatever the algorithm threads between iterations (current
solution, population, temperature...). solution/value is the
iteration's best candidate. record is the iteration's history entry;
None records the best value so far.
- Core (
run): evaluation counting, the clock, buildingState, checkingstopbefore each step, best-so-far underproblem.direction,historyand theOptimizationResult. It knows nothing about populations, temperatures or neighborhoods. - Algorithm:
init,stepand its operators.
simulated_annealing and genetic_algorithm are both built on run (and
so are examples/custom_heuristic.py and the experiments' random search). A
heuristic may still write its own loop (see the hill climber under
Reference implementation), as long as it satisfies the Protocol.
4. How are termination conditions represented?¶
A Stop is a predicate Callable[[State], bool]. State is a frozen
snapshot (iteration, evaluations, elapsed, best_value, direction)
built once per iteration (by run, or by a hand-written loop). direction
defaults to MINIMIZE; run fills it from the problem, so
target_value(0.0) compares in the problem's direction (pass a direction to
override). Helpers build stops, and any_of composes them:
from pymetaheuristics.core import (
any_of, max_evaluations, max_iterations, max_time, target_value)
stop = any_of(max_iterations(500), max_evaluations(10_000), max_time(2.0),
target_value(0.0))
Users may pass any function of State, e.g. a stagnation check that
closes over its own memory. all_of was skipped; add it when someone needs
it. Heuristics built on run report metadata['termination'] == 'stop'
and the final State (the one that satisfied stop) as
metadata['state']; inspect it to see which budget was hit.
5. How are objective evaluations counted?¶
counting(problem) returns a copy of the problem whose evaluate counts
calls, plus a zero-argument function that reads the count:
Algorithms use the counted problem exactly like the original, so counting
never leaks into operator code, and the user's Problem is left untouched.
The count feeds max_evaluations and can be reported in the result's
metadata.
6. How do algorithms expose history and intermediate results?¶
Through the returned OptimizationResult:
historyis a list with one record per iteration. A best-value float is the default; an algorithm may record a dict of stats instead (e.g. GA mean fitness, SA temperature) and documents its shape. A dict record should include the best value so far under'best_so_far'(as the GA does), so convergence curves compare directly with float histories such as SA's.iterations,elapsedandmetadatacover the rest. Underrun,metadataalways hasevaluations,termination('stop') andstate(finalState), plus whatever the algorithm'sextras(carry)returns (e.g. SA'sfinal_temperature).
Live progress callbacks were deliberately left out: the result already holds
everything, and a Stop sees every State. Add an on_iteration keyword
when a real use case (progress bars, plotting during a run) shows up.
Reference implementation¶
tests/core/test_heuristic.py contains a ~25-line hill climber written
against this API. It exercises the Protocol, counting, composed stops,
both directions and seeded reproducibility. It deliberately writes its own
loop to document the bare Protocol; for a new heuristic, prefer run and
copy simulated_annealing in
pymetaheuristics/simulated_annealing/annealing.py (an init/step pair)
or examples/custom_heuristic.py.
For a population-based heuristic, genetic_algorithm in
pymetaheuristics/genetic_algorithm/algorithm.py is the full reference: an
init/step pair on run (the carry is the population, its values and the
elite), one small private helper per generation step, feasibility enforced
before evaluation, and a dict of stats per generation in history.