Runners¶
<easylocal/runners.hpp>; runners/runner.hpp, runners/search_run.hpp,
runners/run_control.hpp, one header per algorithm
A runner couples a search algorithm with the recipes of the services. The algorithm describes the search; the framework owns its execution.
Composing a runner¶
auto runner = make_runner<Algorithm>({...}) // the algorithm's parameters
| sm_recipe | nhe_recipe; // or .with_solution_manager(sm).with_neighborhood(nhe)
auto search = runner.bind(input); // materializes the services
auto result = search.run(solution, algorithm_args..., with(control, tracer));
When the algorithm's parameters_type is a parameter block (a schema and
validate()), the runner holds the parameters and builds the algorithm when it
is bound: make_runner<Algorithm>(parameters) creates it, and
Runner{Algorithm{...}} is rejected at compile time. Any other algorithm is
held as an object: make_runner<Algorithm>(args...) or Runner{Algorithm{...}}.
Invalid parameters throw std::invalid_argument, in Release builds as in
Debug ones, wherever they enter: the constructors of the built-in algorithms
and of their policies (temperature schedules, tabu lists), make_runner, and
bind after a change through parameters(). The message names the field,
temperature.cooling_rate: expected a value in (0, 1), got 2, or gives the
requirement that does not hold. A real parameter with no upper bound (a
temperature, a water level, a factor of the best cost) excludes infinity,
(0, unlimited), so its domain rejects it as any other value out of range.
A custom algorithm gets the same check from
config::require_valid(parameters). Inside a run, the remaining contracts are
asserts.
| Member | Purpose |
|---|---|
parameters() |
the algorithm's parameters, to read or change (parameterized algorithms) |
bind(const Input&) |
build the services and the algorithm for an Input, a BoundRunner (temporaries are rejected) |
configuration() |
the parameters of the algorithm (search), the cost (cost), the SolutionManager (solution_manager) and the neighborhood (neighborhood), as a config::parameter_set |
with_hard_cost() |
the same runner on the hard branch of a hierarchical cost |
BoundRunner: run(solution, args..., [with(...)]) |
run the algorithm |
BoundRunner: initial_solution(), random_solution(rng), input(), better(a, b) |
helpers |
Built-in algorithms¶
| Algorithm | Needs | Parameters | iterations counts |
|---|---|---|---|
runners::FirstImprovement |
moves or cursor, better |
max_evaluations (unlimited: until a local optimum) |
committed moves |
runners::BestImprovement |
moves or cursor, better |
max_evaluations (unlimited: until a local optimum) |
committed moves |
runners::HillClimbing |
random_move, better, better_or_equivalent |
max_idle_iterations, max_evaluations |
proposed moves |
runners::LateAcceptanceHillClimbing |
random_move, better, better_or_equivalent |
history_length, max_idle_iterations, max_evaluations |
proposed moves |
runners::ParetoLateAcceptanceHillClimbing |
random_move, better, a cost::pareto cost, random_solution |
history_length, min_iterations, idle_ratio, second_chance, max_evaluations |
proposed moves |
runners::GreatDeluge |
random_move, better, an arithmetic cost |
initial_level, min_level, level_rate, neighbors_sampled, max_evaluations |
proposed moves |
runners::TabuSearch<List, Aspiration> |
moves or cursor, better, inverse |
max_idle_iterations, max_iterations, max_evaluations, tabu_list: the list's |
committed moves |
runners::FirstImprovementTabuSearch<List, Aspiration> |
as TabuSearch | as TabuSearch, plus candidates: improve_on_best |
committed moves |
runners::AspirationPlusTabuSearch<List, Aspiration> |
as TabuSearch, an arithmetic cost | as TabuSearch, plus candidates: min_moves, max_moves, plus, aspiration_level |
committed moves |
runners::EliteCandidateTabuSearch<List, Aspiration> |
as TabuSearch, an arithmetic cost | as TabuSearch, plus candidates: elite_size, quality |
committed moves |
runners::SimulatedAnnealing<Temperature, Acceptance> |
random_move, better, an acceptance-compatible cost |
a temperature policy (temperature), an acceptance policy, max_evaluations |
proposed moves |
The parameters follow one naming rule. A max_<count> of the run
(max_evaluations, max_iterations, max_idle_iterations,
FixedTemperature's max_accepted) is a cap that ends the run when it is
reached, of type easylocal::limit: a number, or easylocal::unlimited (a
tag of type unlimited_t, which converts to the unlimited limit), written
unlimited in a configuration file, on the command line and in the
TextUI; 0 is a limit of zero, not "no limit". The size of a schedule, which
shapes the search rather than stopping it, is allowed_<x>
(allowed_iterations of the annealing schedules, allowed_reheats,
allowed_running_time), and a threshold the search must pass first is
min_<x> (min_iterations of Pareto Late Acceptance). The bounds of a value,
such as min_tenure and max_tenure, keep their names. The caps are unlimited by
default, except the ones whose value belongs to the problem, which have no
default and must be set: max_idle_iterations, allowed_iterations,
allowed_running_time and min_iterations.
The budget is checked only before evaluating a move, so an empty neighborhood is a local optimum even when the budget is exhausted.
Best Improvement applies the first of the best moves in the order of
moves(): its ties are broken deterministically, where EasyLocal 3's
SteepestDescent drew among them at random. An explorer that wants random
ties shuffles its moves.
Hill Climbing accepts a random move when its cost is better or equivalent to
the current one, so it moves across plateaus where a descent stops. It ends
with idle_limit_reached after max_idle_iterations consecutive proposals
without a strict improvement, or with local_optimum when the neighborhood
proposes no move. Its cost never worsens, so the final solution is the best.
Every runner that draws random moves (Late Acceptance, Great Deluge, Simulated
Annealing, Pareto Late Acceptance) also ends with local_optimum when the
neighborhood proposes none.
Late Acceptance Hill Climbing (Burke and Bykov) also accepts a move whose cost
is better than or equivalent to the cost the current solution had
history_length proposals earlier; the history starts filled with the initial
cost, and with history_length 1 it accepts the moves Hill Climbing accepts.
Its idle count runs from the last improvement of the best cost, and it returns
the best solution found. EasyLocal 3 recorded the best cost in the history;
EasyLocal 4 records the current cost, as in the original algorithm.
Pareto Late Acceptance Hill Climbing (Da Ros and Di Gaspero) is the
multi-objective variant, for a cost::pareto cost: its history holds
solutions, the initial one and history_length - 1 drawn with the solution
manager's random_solution, visited in a circle. A random move of the current
solution replaces it in the history when the candidate dominates it, and the
search moves on to the next solution of the history. Otherwise, with
second_chance, a candidate that dominates the next solution replaces it, and
the search goes on from the solution it replaced, two positions on; else it
moves on to the next solution. Past min_iterations the search ends with
idle_limit_reached as soon as more than idle_ratio of the iterations went
without a replacement. Its result is the run's front, with the first solution
by objectives as solution.
Tabu Search applies at each iteration the best admissible move of the whole
neighborhood, even a worsening one, ties broken uniformly at random. A move is
admissible unless the tabu list forbids it, through the neighborhood's
inverse (see NeighborhoodExplorer), and the
aspiration criterion does not lift the prohibition:
aspiration::ByObjective (the default) admits a tabu move that would improve
the best cost, aspiration::None never does and then skips evaluating tabu
moves. Another criterion is a value with overrides(run, candidate, best) ->
bool and static constexpr bool needs_cost, which aspiration_for<A, Run>
checks. When every move is tabu, the least tabu one (the shortest remaining
tenure) is applied. The search ends with idle_limit_reached after
max_idle_iterations iterations without improving the best cost, and returns
the best solution found. FirstImprovementTabuSearch stops the scan at the
first admissible move that improves the current cost (with improve_on_best,
the best cost); without one it applies the best admissible move. Both take the
RNG as a run argument, for the ties.
The four tabu searches share one parameter block,
TabuSearchParameters<ListParameters, CandidateParameters>: the limits of the
run, the list's parameters as the group tabu_list, and those of the
candidate strategy, the scan of each iteration, as the group candidates
(search.candidates.* in a configuration):
candidates::FirstImprovementParameters, AspirationPlusParameters and
EliteListParameters. The full scan of TabuSearch,
candidates::FullParameters, has none, and no group:
make_runner<AspirationPlusTabuSearch<tabu::RandomTenure>>(
{.max_idle_iterations = 500,
.tabu_list = {.min_tenure = 5, .max_tenure = 15},
.candidates = {.min_moves = 10, .max_moves = 40}})
Two runners implement Glover's candidate list strategies, whose levels are values of the cost (an arithmetic cost):
AspirationPlusTabuSearchexamines admissible moves untilplusmore after the first one under the aspiration level, but at leastmin_movesand at mostmax_moves, and applies the best examined.EliteCandidateTabuSearchkeeps, from a full scan, theelite_sizebest admissible moves besides the one applied; the next iterations evaluate only the kept moves still valid and apply the best admissible one while its cost is at most the quality level, otherwise a full scan builds a new list.
Both levels are a factor f (aspiration_level, quality) of the best cost
b, kept on the worse side of it whatever its sign: b + (f - 1) * |b|, which
is f * b for a positive best cost.
Tabu lists in runners::tabu; their parameters are the group tabu_list:
| List | Parameters | A move stays tabu |
|---|---|---|
FixedLength (TS1) |
tenure |
for tenure iterations after the move it would undo |
RandomTenure (TS2) |
min_tenure, max_tenure |
for a tenure drawn uniformly in [min_tenure, max_tenure] |
Cyclic (TS3) |
period, tenures |
for the current tenure, which takes the tenures in turn every period iterations |
Reactive (TS4) |
increase, decrease, repetitions, chaos, cycle_length, max_tenure, verify_equality |
for a tenure that reacts to revisited solutions; needs the solution hash and random_move |
Frequency (TS5) |
threshold |
while its attribute was applied in more than threshold of the iterations; needs tabu_attribute, not inverse |
ObjectiveBased |
tenure |
while its cost is equivalent to one reached in the last tenure iterations, by the cost semantics (equivalent); needs neither inverse nor attribute |
LimDynamic (limited dynamic) |
min_tenure, max_tenure, idle_threshold |
for a tenure that grows by one after idle_threshold idle iterations and falls back to min_tenure on an improvement or at max_tenure |
Foo (FOO, Fluctuation Of the Objective) |
window, increment, fluctuation |
for a tenure that grows by increment when the costs of the last window iterations spread less than fluctuation, and shrinks by one otherwise; needs cost::delta, whose spread across two hard levels of a cost::hierarchical is infinite, so a window that changes the hard cost shrinks the tenure |
RandomFoo (randomized FOO) |
ranges of the three | as Foo, drawing them again at each window |
Most lists forbid moves through the neighborhood's inverse, Frequency
through its tabu_attribute, ObjectiveBased through the candidate's cost,
evaluating each move before the tabu check (see
NeighborhoodExplorer); each requires only what it
uses. Reactive (Battiti and Tecchiolli) starts with tenure 1 and recognizes
solutions by solution_hash (see SolutionManager); a
hash collision counts as a revisit, unless verify_equality keeps a copy of
each visited solution and compares those with the same hash, which needs
solution equality (has_solution_equality, otherwise the run throws
std::invalid_argument). A solution revisited within cycle_length
iterations multiplies the tenure by increase (up to max_tenure) and updates
the average cycle length; without such cycles for longer than the average, the
tenure is multiplied by decrease. A solution visited more than repetitions
times counts as chaos; after more than chaos counts the memory is reset and
the search escapes with 1 + (1 + r) * average / 2 random moves (r uniform
in [0, 1)), applied whatever their cost, counted as iterations and
recorded in the list like the others.
The names of the lists are those of Da Ros, Di Gaspero and Schaerf's
performance analysis of tabu list strategies: LimDynamic is a limited
dynamic tenure, Foo the Fluctuation Of the Objective scheme of Blöchliger and
Zufferey, and RandomFoo its variant that draws the parameters again at each
window.
The lists with one tenure for all moves (Reactive, LimDynamic, Foo,
RandomFoo) apply the current tenure to every move they hold: a move is tabu
while it is younger than the tenure, so a growth also makes tabu again the
older moves it covers. Each list keeps the moves that the next growth can
reach: those of the last tenure + increment - 1 iterations before a Foo
window ends, of the tenure that increase would bring for Reactive.
A tabu list is a value with its parameters that makes, for each run, a state
with make_state<Run>(). tabu_tenure(candidate) gives the iterations left
before a candidate move is admissible, nothing when it is (tabu_candidate:
move(), forbidden_by(tabu_move) through the inverse, attribute(), and,
when the state declares static constexpr bool needs_cost = true, cost() and
equivalent_cost(other), members of tabu_candidate<Run, true> only, so a
list that reads the cost without declaring it does not compile);
update(step, rng) records an applied move (tabu_step: move(),
solution(), cost(), iteration(), improved_best(), attribute(),
solution_hash(), the last two when the problem has them). A state with
escape_moves() asks for that many random moves, and one with
current_tenure(), a tenure for all its moves, has it traced as
tabu_tenure_changed at the start of the run and whenever it changes.
tabu_list_for checks a custom list.
Great Deluge accepts a move that improves the current cost or whose cost does
not exceed the water level. The level starts at initial_level times the
initial cost and is multiplied by level_rate every neighbors_sampled
proposals; the search ends, completed, when the level falls below
min_level times the best cost, and returns the best solution found. The level
is a value of the cost, so Great Deluge requires an arithmetic cost
(cost::arithmetic), and positive, since the levels are factors of it: a
best cost of zero ends the search.
Simulated Annealing returns the best solution found. Temperature policies in
runners::temperature, all configurable:
| Policy | Parameters | Stops when |
|---|---|---|
Classic |
initial/final temperature, cooling rate, samples per temperature | the final temperature is reached |
FixedLength |
initial/final temperature, cooling rate, allowed_iterations |
the allowed iterations are spent, spread over the levels |
Cutoff |
as FixedLength, plus accepted_ratio |
the allowed iterations are spent; cools only after accepted_ratio of a level's share of them have been accepted, never on samples |
Hybrid |
as Cutoff | the allowed iterations are spent; cools on samples or acceptances (samples_per_temperature(), accepted_limit()) |
FixedTemperature |
temperature, allowed_iterations, max_accepted (unlimited by default) |
the allowed iterations are spent, or max_accepted moves are accepted; never cools |
TimeBased |
initial/final temperature, cooling rate, running time, accepted per temperature (unlimited: cools only on time) | the running time is over or the final temperature is reached; levels share the time |
Reheating<Descent> |
descent: the schedule's, plus allowed_reheats, reheat ratio, first-descent share |
the last descent ends; each reheat restarts from reheat_ratio times T0 |
The levels of a schedule are counted once, from its temperatures: the
smallest k with initial_temperature * cooling_rate^k <= final_temperature,
a count within rounding of an integer being that integer, so that 1 to 0.001 by
0.1 is three levels. Classic and TimeBased end after that many levels, and
the schedules with a budget spread it over them.
TimeBased reads the clock (std::chrono::steady_clock; BasicTimeBased<Clock>
takes another one) as a run checks its time limit: at an interval of proposals
that adapts so that readings come about a millisecond apart, and at each early
cooling. Its trajectory depends on the speed of
the machine, so equal seeds do not give equal runs.
Reheating<Descent> reheats any schedule whose parameters have an
initial_temperature (all but FixedTemperature): a first descent, then up to
allowed_reheats descents that restart from reheat_ratio times the initial
temperature. When the schedule has a budget, allowed_iterations or
allowed_running_time, the first descent spends first_descent_share of it
and the reheats divide the rest evenly, at least one iteration each, so
allowed_reheats may not exceed the iterations the first descent leaves (a
requirement of the block); Classic, which has none, runs whole at every
descent. The schedule's parameters are the group descent:
{.temperature = {.descent = {...}, .allowed_reheats = 2}},
search.temperature.descent.* in a configuration. It calibrates when the
schedule does, keeping the reheat temperature above the final one.
Reheating<Hybrid> is EasyLocal 3's annealing with reheating.
For a complete extension example, see
Customizing Simulated Annealing.
It walks through the library's run implementation, configures this wrapper
and writes a temperature policy that reheats after consecutive rejections.
Every built-in policy can estimate its initial temperature (the constant one
for FixedTemperature) with calibration_samples > 0: before the run, Simulated
Annealing evaluates that many random moves at the initial solution, without
applying them, and the policy starts from the temperature at which a worsening
move of average size is accepted with probability initial_acceptance (0.5 by
default; 0.2 suits a good initial solution), as in Johnson et al. (1989).
Improving moves and infinite deltas (a hierarchical hard level) are ignored;
without worsening moves, or when the deltas ask for a temperature that is not a
representable number, initial_temperature stays, and the estimate is kept
above the final temperature. The sampled moves count as evaluations, not as
iterations. A custom policy opts in by modelling
calibrating_temperature_policy: calibration_samples() and
calibrate(std::span<const double> deltas).
runners::MetropolisAcceptance (the default) requires cost::delta (see
Cost). On a cost::hard_soft cost the delta of a move that worsens
the hard cost is infinite, so Simulated Annealing never accepts it, at any
temperature, and cannot cross an infeasible region as EasyLocal 3's
HARD_WEIGHT let it do. To cross one, write the cost as a single weighted sum,
cost::sum(component<H>() * 1000, component<S>()), and check that the result
is feasible. Another acceptance criterion is a value with
accept(candidate, current, temperature, rng) -> bool, which
acceptance_policy_for<Acceptance, Cost, RNG> checks. The parameters of SimulatedAnnealing<Policy> are
SimulatedAnnealingParameters<Policy::parameters_type>, the policy's under the
group temperature and the evaluation budget max_evaluations:
make_runner<SimulatedAnnealing<Classic>>({.temperature = {...}}),
search.temperature.* in a configuration. The temperatures have no default,
since they live in the scale of the cost, and neither has the budget of a
schedule that has one; the rest of each block does. A calibrating schedule with calibration_samples above 0
needs a cost with cost::delta; with any other cost the run throws
std::invalid_argument.
Results¶
Built-in algorithms return search_result<Solution, Cost>: solution, cost,
evaluations, iterations, termination (termination_reason::completed,
local_optimum, evaluation_budget_exhausted, cancelled, target_reached,
idle_limit_reached, time_limit_reached). With a cost::pareto cost they return
pareto_search_result<Solution, Cost>, a search_result that adds front: the non-dominated
solutions the run reached, as pareto_point{solution, cost}, ordered by their
objectives. The search_run keeps them in a pareto_archive as the run starts,
evaluates solutions and commits moves, so every algorithm has a front without
doing anything. The archive compares costs with the run's relations
(better, equivalent, so a root compare of the cost expression applies):
a solution enters unless an archived one is better or has an equivalent cost,
and removes those it is better than. So the front holds one point per
non-dominated cost, the first reached.
The run options choose otherwise, for a run or a solve:
options.keep_front({.keep_equivalent = true, .max_front_size = 100}) (a
pareto_archive_parameters, the field front of run_options) keeps every
distinct solution of an equivalent cost (by the problem's solution equality),
and bounds the front: once it holds max_front_size points (at least 1,
unlimited by default), a solution enters only by removing points it
dominates. On a plateau, keeping every solution makes the front grow with the
plateau and each offer scan it, so bound it.
A reached target is the termination reason also when it coincides with a local
optimum or the end of the algorithm; a cancellation takes precedence. Solvers and tools
require only search_result_for<Result, Solution, Cost>: solution and cost.
Writing an algorithm¶
An algorithm is a class with a run member taking the search_run first:
class MySearch
{
public:
using parameters_type = MyParameters; // with a schema: configurable
explicit MySearch(const MyParameters&);
template<class Run>
auto run(Run& run, typename Run::solution_type solution /*, extra args */) const;
};
With a schema and validate() on MyParameters, the runner holds them and
builds MySearch at bind; they are configurable as search.* with no other
member. Without one, parameters_type serves only app registration.
Extra run arguments (an RNG, for example) are passed through
search.run(solution, extra...).
An algorithm may also declare what it asks of the services it searches with, so that a registration on a neighborhood it cannot use names the capability that is missing instead of the concepts that failed:
using search_requirements = easylocal::runners::requirements<
easylocal::runners::needs::random_moves,
easylocal::runners::needs::non_worsening>;
| Capability | What the services must provide |
|---|---|
needs::enumerated_moves |
the explorer's moves(), or first_move() and next_move() |
needs::random_moves |
the explorer's random_move(solution, rng) |
needs::random_solutions |
the SolutionManager's random_solution(rng) |
needs::strict_improvement |
a cost with better() |
needs::non_worsening |
a cost with better_or_equivalent() |
The capabilities are reported in the order they are declared, one message for the first the services do not provide; the neighborhood's before the cost's reads best. They only choose the message: an algorithm that runs is never rejected because of what it declares, and one that declares nothing keeps the generic message. The built-in algorithms declare theirs.
search_run¶
| Member | Effect |
|---|---|
limit_evaluations(n) |
evaluation budget, including the initial evaluation: a count, or easylocal::unlimited |
start(solution) -> evaluation |
first evaluation, run_started, progress |
evaluate_solution(solution) -> evaluation |
another solution (a population, a history): counts, traced as visited, offered to the archive |
front() |
with a cost::pareto cost, the archive of the non-dominated solutions reached |
should_stop() -> bool |
cancellation, a reached target cost, an exhausted evaluation budget or a passed time limit; the reason is recorded |
moves(solution), random_move(solution, rng) |
neighborhood access; unions emit selection events |
evaluate_move(solution, current, move) -> candidate |
counts, move_evaluated, progress |
commit(solution, current, candidate, move) |
applies, move_accepted |
next_iteration() |
advances the iteration counter |
incumbent_updated(previous, cost) |
incumbent_updated event; the cost checked against the target |
commit_improvement(solution, current, candidate, move) |
commit, then incumbent_updated, for an algorithm whose current solution is its best (Hill Climbing, First and Best Improvement) |
finish(solution, cost[, reason]) -> search_result |
local_optimum (if that is the reason), run_finished |
better, equivalent, better_or_equivalent |
cost semantics |
evaluations(), iterations(), target() |
counters, the caller's target cost |
context(), neighborhood_explorer(), input(), solution_manager() |
the search context |
evaluation(), emit(event), tracer(), control() |
escape hatches (bypass counters and events) |
with_evaluation(wrap) |
a run that evaluates through wrap(evaluation()), a decorated facility (a delay, a cache), and shares the context, the cost relations, control, tracer, deadline, target and the budget left |
evaluation / candidate expose cost(); finish without a reason uses the
one recorded by should_stop(), or completed.
A decorated facility, the one wrap returns to with_evaluation, has the
interface of the facility it wraps: the types evaluation_type and
candidate_type, evaluate(solution) -> evaluation_type,
evaluate_move(solution, current, move) -> candidate_type and
commit(solution, current, candidate), the costs of an evaluation and of a
candidate being cost() of the run's cost type. It forwards them, adding its
own work (a delay, a count, a cache); the run keeps the counters, the events
and the limits around it.
An algorithm that returns the best solution it visited, rather than the last,
keeps it in a best_so_far{solution, cost}: best.update(run, solution,
current) copies the solution when its evaluation is better than the best,
emits incumbent_updated, and returns whether it did; at the end
run.finish(std::move(best.solution), std::move(best.cost)). Late Acceptance,
Great Deluge, Simulated Annealing and the tabu searches use it. An algorithm
whose current solution is its best one applies an improving move with
run.commit_improvement(...), which emits incumbent_updated too: so every
built-in runner reports each new best cost.
Run options¶
| Spelling | Effect |
|---|---|
with(control) |
cancellation (std::stop_token) and progress observer |
with(tracer) |
semantic trace events |
with(control, tracer) |
both |
stop_at(target), with(...).stop_at(target) |
stop as soon as the best cost is at least as good as target |
options.without_target() |
the same options without their target (a pipeline gives them to a stage that is not the last) |
max_evaluations(n), with(...).max_evaluations(n) |
stop once the run has made n evaluations, the initial one included; a runner's own max_evaluations, if smaller, still applies; termination evaluation_budget_exhausted |
timeout(5s), timeout(2.5), with(...).timeout(...) |
stop once the time limit has passed since the run started: a std::chrono duration or a number of seconds; termination time_limit_reached |
run_control{stop_token, observer} calls observer(const run_progress&) with
evaluations, iterations and evaluation_limit. A frontend that shows the
progress from another thread stores it in a shared_run_progress from the
observer and loads a copy when it draws, as the TextUI and the REST server do.
control.stop_token() gives the token back, for a control of its own that
shares it, and control.observing_progress(observer) a copy with another
observer of the progress.
control.observe_best_cost<Cost>(best) adds an observer of the best costs:
the run calls best(const Cost&) with its first cost, then with each cost
better, by its cost semantics, than those it reported, the last one being the
best the run found. It is called on a commit that improves, not on each
evaluation; costs of another type than Cost are not reported. A
shared_best_cost<Cost> keeps the last one for another thread, behind a
mutex: the TextUI's progress line (best=) and REST's progress.best show it.
The options combine in any order:
with(control).timeout(30s).max_evaluations(100000).stop_at(0).
They are the fields control, tracer, target, timeout (none: no time
limit) and max_evaluations (an easylocal::limit, unlimited by default) of
run_options. A
negative time limit, a NaN one (a number of seconds or a floating-point
duration) and an infinite number of seconds throw std::invalid_argument; a
duration beyond what the clock counts is the longest it can. search_run
checks the deadline with the other stopping conditions, reading the clock at
an interval of checks that adapts so that readings come about a millisecond
apart: a loop that checks at every move reads it rarely, one that checks once
per long iteration reads it every time. There is no timer thread.
The target converts to the runner's cost type and is compared with its cost
semantics (better_or_equivalent). search_run checks the costs reached in
start, commit and incumbent_updated, so every algorithm that checks
should_stop() honours a target without further code. A run that ends with
target_reached returns a solution that meets the target: with a partial
order, such as a cost::pareto cost, the reaching cost need not dominate the
best one, so best_so_far keeps the first cost that meets the target, and a
run with a front returns a point of the front that meets it. A floating-point
cost updated by deltas drifts from its full evaluation by rounding errors: when
a committed cost misses the target only within cost::tolerance{}, the run
evaluates the solution in full (not counted in the budget) before deciding, so
a zero target, such as the one of until_feasible(), is reached by a cost that
the deltas left at 1e-17. The same options are accepted by every solver's
solve(input, options).
Design choices¶
- One
runmember. Algorithms describe their logic; counters, budget, cancellation, progress and events belong tosearch_run, so they behave the same in every algorithm. What the algorithm alone knows stays its own: counting an iteration, checkingshould_stop()and reporting a new incumbent (commit_improvement(),best_so_far). - Every runner is cancellable. A run always carries a
run_control; checkingshould_stop()is the whole contract. The cost of an inactive control is a null check. - Tracing is compile-time. Without a tracer, events are not even constructed, nor the routes of the moves of a neighborhood union.
- Per-run state is local.
BoundRunner::runisconst, and each run starts from a new algorithm, built from the parameters or copied: a custom algorithm may keep state during a run, never from one run to the next. - The algorithm class is its own key. No tag types: the class names the
algorithm in
make_runnerand in app registrations.