Roadmap¶
Planned evolutions of EasyLocal: directions the design has already been prepared for, not yet scheduled. Nothing here is a promise of the API stability policy; an item becomes part of the API only when it is released and listed in the changelog.
Asynchronous runs on a Session¶
Why. A Session runs a registered runner on the calling thread:
session.run("sa", with(control)) returns when the runner has finished. That
suits a program that only waits for the result, and the REST service, where
every run has a Session of its own on a worker thread. It does not suit an
interactive frontend, which keeps reading the session (the current solution,
the selected move, the pages it shows) while a runner works, and must stay
responsive. The TextUI does this today on its own: it copies the app, the Input
and the current solution, runs the runner on a worker thread with
app.run("name", ...), and puts the result back when it arrives.
What. The Session would offer the same in two steps, for every frontend:
session.start("sa")prepares an independent run, with copies of what it needs, arun_controlfor cancellation and progress, and its own RNG drawn from the session's, to be executed on another thread;session.accept(result)makes the run's result the current solution, unless the session has moved on since the run started.
When. When a second interactive frontend needs it, for example a GUI, a web frontend, or REST resources for interactive sessions (selecting and applying moves over HTTP). The TextUI would then move to the same mechanism, and the design would start from the needs of two real users.
The Pareto front in the frontends¶
Why. A run with a cost::pareto cost returns the non-dominated solutions
it reached: app.run("name", ...) carries them in named_run_result.front,
the Session keeps them (last_run_front()), cli::run prints them and REST
returns them with the solution. The TextUI still shows one solution, the
current one, so a multi-objective app shows a single trade-off there.
What. A TextUI page that lists the front of the last run, each point with its cost, and makes one of its solutions the current one.
When. Next, as a change of its own: the other frontends already show the front.
A failed run in the trace and in the result¶
Why. An exception thrown in the middle of a run, by a hook of the problem
or by the tracer, ends the run without a trace record and leaves solve()
with nothing: MultiStart loses its best start so far, a pipeline its earlier
stages and their reports. A trace shows only that the run did not finish
(eltr.py marks it unfinished), not why, and a long solve that fails at its
last start returns nothing of the work done.
What.
- a
run_abortedcore event, emitted bysearch_runwhen an exception leaves the run, with the counters and the cost reached, before the exception goes on; - a
solve_failedexception thrown by the solvers, which carries the best result so far and the stage reports, with the original exception nested.
When. Not scheduled: when a long-running use, such as a REST service or a tuning campaign, needs the partial results.
Python and Julia bindings¶
Why. The problem components are C++: the SolutionManager, the neighborhood explorers, the cost components and their deltas, compiled for speed. Experiments around them, such as choosing runners and parameters, running many instances and seeds, and analysing the results, are often more convenient in a notebook or a script.
What. An optional adapter, like the TextUI and the REST service, that
exposes a compiled app to Python or Julia: list its runners, set their
parameters through their parameter set, create a Session on an Input, run
runners by name, and read solutions, costs and traces. The C++ problem is
written once; the scripting side never reimplements it. It would build on the
same public surface the other adapters use (app, Session, app.run, the
parameter sets), with a binding library for Python, such as nanobind, and
a C interface for Julia.
Each frontend is a template on the app's type today: the TextUI and REST are compiled with the problem. A binding needs a session whose type does not depend on the app's, a runtime-erased Session (runner names, parameter paths, solutions and costs as text or JSON, runs by name), which the TextUI and REST could then share instead of each instantiating its own; after 4.0.
When. Not scheduled. Like the other adapters, it would live outside
EasyLocal::Core and could become a separate project once the API is stable.
Shifting penalty¶
Why. When the hard constraints are folded into the cost with a fixed
weight, the right weight depends on the instance and on the phase of the
search: too low and the search stays infeasible, too high and it cannot cross
infeasible regions to reach better feasible ones. EasyLocal 3 had a
ShiftingPenaltyRunner that wrapped any move runner and adapted the weights
of the constraints while the search ran.
What. Weights that change during a run, raised after a number of
iterations in which a constraint stays violated and lowered after a number of
iterations in which it is satisfied, within a range and with a random
perturbation. In EasyLocal 4 the weights live in the cost expressions
(weighted, hard_soft), so the design starts there: dynamic weights in the
expression tree, which a runner, or a wrapper around any runner, adjusts,
with the deltas staying consistent with the current weights.
When. Not scheduled. It depends on how the cost expressions settle.
A name for every service¶
Why. A cost component may have a name(), which the reports and the
interactive tester show in place of its position, and a neighborhood explorer
a static name(), which the tester puts on its Move page. A SolutionManager,
a delta cost component and a runner have none, so a frontend that lists them
names them by their position or by their registration. The two name() that
exist are also read for two different purposes: the label of a component, and
the path a configurable class is configured under (cost.<name>.*), where it
is required rather than optional.
What. One rule for the whole model: which services may be named, whether a
name is a label, a path, or both, and what names a service that has none. A
mixin, named<"2-opt"> or a name_base beside input_base and
parameters_base, would spare the boilerplate and make the capability visible
in the class's declaration; it has to stay optional, since a class that nobody
lists needs no name, and it must not collide with the static name() the
configurable components already require.
When. Not scheduled. It is worth settling before the frontends grow: the launcher, a GUI and the REST resources all list services.
A tester on an app without a neighborhood¶
Why. An app is built one component at a time, and the tools open on one without runners: the tester shows the Input, the solutions and the moves, and has no runner to run on its Run page. A neighborhood is still required, so the first step of a model, a SolutionManager with its cost components, cannot be opened in the tester, which is where it would help most: the cost of a solution read from a file, the value and the description of each component, the initial and the random solutions.
What. A stub service for an app whose neighborhood recipe is still
unconfigured: BoundApp builds a neighborhood explorer that neither
enumerates nor draws moves, so every capability of the Session about moves is
already false and its move commands drop out of the overload set. The Session
says so with a flag of its own, and the tester leaves out its Move page rather
than showing an empty one; App::configuration() has no neighborhood.*
block, and check() reports the neighborhood as missing instead of failing on
it.
When. Not scheduled.
A launcher across SolutionManagers¶
Why. The TextUI's launcher opens several apps on the same problem and passes the Input and the current solution from one to the next, so the apps must have the same SolutionManager. Some problems are explored with different ones: two representations of a solution (a permutation and an assignment, for example), or a relaxed model next to the full one. Moving a solution between them today means saving it from one app and loading it in the other, when the two formats agree.
What. A launcher whose apps may have different SolutionManagers, with
conversions between their solutions declared by the problem, for example a
hook convert(const Input&, const SolutionA&) -> SolutionB found by ADL. The
launcher converts the current solution when another app opens, and checks at
compile time that every app it may switch to can be reached by a conversion;
apps with the same SolutionManager keep passing it unchanged.
When. Not scheduled.
Adaptive neighborhood selection¶
Why. In a multi-neighborhood search the share of moves drawn from each
neighborhood is fixed by the user, while the neighborhoods that pay off change
with the instance and along the run. EasyLocal 3 had a
SimulatedAnnealingWithLearning runner that learned these shares during the
search.
What. A selection policy for the union of neighborhoods that updates the probability of each one from what its moves achieve (improving, sideways and accepted moves, the improvement obtained, the time spent evaluating), with a learning rate and a lower bound on each probability. It belongs to the multi-neighborhood composition rather than to Simulated Annealing, so that every runner that draws random moves could use it. A simpler fixed policy fits the same place: an optional size of each child's neighborhood for a solution would let the union draw a child in proportion to it, and so draw uniformly among all the moves when the children do.
When. Not scheduled.
The fixed cost of a neighborhood union¶
Why. A union dispatches on the variant that is its move type several times
for each move it evaluates: the iterator of the enumeration holds a variant of
the children's iterators and visits it to advance and to build the tagged move,
and is_valid(), make_move() and each delta visit the move again. The
bindings of the union are built on every call of delta_bindings(), which the
evaluation makes once per component of every move, where a neighborhood with a
delta layer returns the tuple it holds. None of this showed while a union
without a delta everywhere evaluated its components in full; now that the
deltas are used per child, it is what is left. The benchmark measures it on the
TSP (results main-d2ceb00): with every delta attached, a steepest descent
costs 5.1 nanoseconds per evaluation on the 2-opt neighborhood alone and 20.5
on the union of 2-opt and swap, a first improvement 4.3 against 17.5. The
deltas there are O(1), so what the union adds is the whole difference; with a
delta that scans the solution it would disappear in the noise.
delta_bindings() is built once, when the union is constructed, and returned
by reference, since 4.0.0-alpha.3, and the infrastructure benchmarks now
measure the union against the same moves with its machinery written by hand:
tsp-union-medium enumerates them child after child, tsp-union also
evaluates them, with one delta that visits the move in place of the union's
binding per child. The union's iterator costs about 0.7 nanoseconds per move
to enumerate, and the two are within 5% of each other under a runner. Neither
the iterator nor the bindings, then, are what a union costs: what is left
against a single neighborhood is the variant move type itself, which every
is_valid(), make_move() and delta visits, and the second child's delta.
What. Evaluating the moves of a child with that child's own move type: the union's enumeration would hand a runner the child it is in, and the variant would be needed only to carry the move the runner keeps, so the visits of the evaluation disappear. It changes how a runner walks a neighborhood, which is the same question as where a first improvement starts its scan.
When. Not scheduled, and the measurements bound what it can give: the union's own machinery is already free, so what it would remove is the cost of the variant alone.
Where a first improvement starts its scan¶
Why. FirstImprovement applies the first improving move of the
enumeration and starts the next scan at the first move again, so the moves the
previous iterations rejected are scanned once more before a new one is
reached. EasyLocal 3's FirstDescent instead starts each scan at a move drawn
at random and wraps around, which is why the two walk different trajectories.
On a large neighborhood that is many more evaluations for the same number of
iterations: on a 300-city TSP, with the 2-opt neighborhood of about 44500
moves, EasyLocal 3 evaluates some 1100 moves per iteration and EasyLocal 4
some 13500. It also decides which moves are evaluated: a union enumerates
its children one after the other, so a scan that always restarts evaluates
almost only moves of the first child, and a delta attached to that child alone
looks nearly free. The benchmark measures the two walks apart, fd and rfd,
rather than comparing them.
What. A scanning strategy for the algorithms that apply the first
improving move: from the first move (what it does today), from a move drawn
at random, or resuming where the previous scan stopped. A scan that does not
begin at the first move needs a neighborhood it can enter at a given move,
which the cursor protocol (first_move, next_move) gives and a moves()
generator does not: the strategy is available where the explorer has a cursor,
and a union needs a cursor of its own, which it has not. The same question is
behind the candidate strategies of Tabu Search below.
When. Not scheduled. It is worth settling before the benchmarks are read as a comparison of the two frameworks' first descent.
Candidate strategies of Tabu Search¶
Why. The four tabu searches (TabuSearch, FirstImprovementTabuSearch,
AspirationPlusTabuSearch, EliteCandidateTabuSearch) are one algorithm with
four ways of scanning the neighborhood; they already share one parameter
block, TabuSearchParameters<List, Candidates>, but each strategy is still a
runner of its own.
What. One TabuSearch<List, Aspiration, Candidates> with a candidate
strategy policy, candidates::{Full, FirstImprovement, AspirationPlus,
EliteList}, whose parameters are the group candidates they already have,
and aliases for the names of EasyLocal 3.
When. Not scheduled.
Cooperative runners¶
Why. A pipeline runs its stages one after the other, each from the solution of the previous one. Some searches combine runners differently: several runners at the same time, on different neighborhoods or with different parameters, that exchange their best solutions (a portfolio, an island model), or that alternate on the same solution under a common control, without a fixed order.
What. A solver of cooperative runners: the runners, a policy that says when they run (in parallel threads, or interleaved) and what they share (the best solution found so far, at given intervals or when one improves it), a common stopping criterion and budget, and the effort and best result of each runner in the result. Its determinism with a seed when the runners run in parallel is part of the design.
When. Not scheduled.
Parallel search¶
Why. A run uses one thread. EasyLocal 3 explored a neighborhood in
parallel (ParallelNeighborhoodExplorer, over TBB) and kicked in parallel,
and the machines a search runs on have many cores, idle for the whole run. The
only parallelism today is outside a run: the REST service runs independent
requests on a pool of workers.
What. Three levels, each useful on its own:
- Inside a neighborhood. A scan that evaluates every move (Best
Improvement, Tabu Search) splits the moves over threads and reduces the
results deterministically, so the move chosen is the one the sequential scan
would choose. The delta cost components are
constmembers over an immutable Input, so they can already be called concurrently unless a component caches something; the contract would have to say so. - Across runs. Runners that search the same Input at once, sharing their best solutions or nothing at all (a portfolio): the solver level of Cooperative runners.
- Across solves. Instances and seeds run in parallel, which belongs to the experiment configurations rather than to the library.
The design questions are shared: who owns the threads, how a budget, a progress observer and a cancellation cross them, what a trace of a parallel run records, and what a seed guarantees when the work is split.
When. Not scheduled. The first level is the one an EasyLocal 3 program that used the parallel explorer needs.
Kicks, Iterated Local Search and Variable Neighborhood Descent¶
Why. EasyLocal 3 had kickers (a perturbation of several moves applied at
once to leave a local optimum), the TokenRingSearch and
VariableNeighborhoodDescent solvers. A pipeline runs its stages once, in a
fixed order: it cannot go back to an earlier stage when a later one improves,
nor perturb its solution between rounds, which Iterated Local Search and VND
need.
What. A kick, a sequence of random (or enumerated) moves of a neighborhood applied together, as a stage of its own; pipeline rounds (run the stages again, from the last solution, while a round improves the best cost or until a budget), and a restart rule (back to the first stage on an improvement), which together give Iterated Local Search (descent, kick, acceptance) and VND (descents on neighborhoods in turn, back to the first after an improvement). The rounds share the solve's budget and progress, as the stages do.
When. Not scheduled.
A modelling layer¶
Why. Writing a delta cost component by hand is where the bugs of a search
are: the checks of the library exist to catch them, and an incremental
evaluation has to be rewritten whenever the neighborhood changes. EasyLocal 3
carried a tentative modelling layer (AutoState, with Tommaso Urli) that
derived the cost and its changes from a declarative model; it never became
part of the framework people used, but the need it addressed did not go away.
Constraint-Based Local Search answers it with a declarative model whose
network of invariants derives the cost and its change under a move:
OscaR.cbls as a library,
Hexaly (formerly LocalSolver) as a black-box solver
that also owns the search.
What. Not in this library. The core stays a framework where the search is
written by its user, and the modelling layer grows outside it, in
easylocal-model (namespace easylocal::model), which consumes the released
headers and nothing else: whether that is enough is also the measure of how
extensible this API is from the outside. The design it starts from:
- the solution stays the decision state alone, copied and hashed as it is
today, and the network of invariants lives in an evaluator owned by the
SolutionManager, which follows the current solution (
sync), answersprobe(move)without committing andcommit(move)when the explorer applies one. A solution that carried its network would pay for it at every copy, which is what sankAutoState; - the graph is built at run time from the instance data, and is made of typed kernels (sum, weighted sum, element, count, min and max, capacity, all-different violations, the successor of a sequence): the dispatch is per node, over a whole vector, not per element;
- a move is differentiated on a trail by default (apply the variable changes, propagate incrementally, read the objective, roll back), with a closed-form fast path for the kernels that have one, which are the linear ones on the hot path;
- a constraint exposes its violation and the violation imputed to each variable, which is what guided neighborhoods (the most violated variable, then the value that reduces it most) are built on, and what makes an explorer that nobody wrote for the problem possible;
- what a model produces are the pieces of this library: each constraint and each objective is a leaf of a cost expression, with a SolutionManager, the neighborhoods and the deltas, so hard and soft costs, hierarchical costs and Pareto costs keep working. A declared component and a hand-written one then compose in the same cost expression: the hot component keeps its closed delta, the rest is declared. That mixing is what justifies doing this here rather than using Hexaly.
A catalog of standard representations (a permutation, an assignment, a selection, a sequence per resource) with their moves, their explorers and the structures they keep consistent complements the model: the moves say what changes, the model says what it costs.
The price is a factor of 3 to 10 per move against a hand-written delta. The
layer is there to write a problem in an afternoon instead of a week, and to
serve as a reference the hand-written components are checked against (the
delta checks of testing compare a delta with a full evaluation already);
it is not there to win benchmarks.
When. Not scheduled. A vertical spike on the assignment problem comes
first, with a few kernels and no fast path, to answer two questions: whether a
mutable evaluator behind the const delta_evaluate works without touching the
core, and what it costs, measured against examples/assignment.
Parameter tuning¶
Why. The parameters of a search are usually tuned with an automatic
configurator: irace (iterated
racing), SMAC (Bayesian optimization with
random forests) or Optuna (Bayesian optimization in
Python, define-by-run). irace is supported: every parameter declares its
domain (check(app) fails on one without), conditions and requirements
between parameters (only_if, config::require) are exported as irace
conditions and [forbidden] expressions, and cli::run --tuning.irace writes
the scenario. What is left:
What. SMAC and Optuna exporters from the same domains and conditions: the configuration space (ConfigSpace) and a target function for SMAC; the search space as a Python function for Optuna, which with the Python bindings (above) could also run the app in-process.
When. Not scheduled.
Experiment configurations¶
Why. Tuning and benchmarking produce configurations (the tuned values, the fixed ones, the instances and seeds) that must be kept with the version of the code that produced them, and found again for a paper or a later comparison. The tool that versions them is outside EasyLocal, but EasyLocal programs must make it easy, with little boilerplate.
What. What a program could give to such a workflow:
- its effective configuration, every parameter with its value, in the
path = valueformat that--configreads back; - its identity in its output: the EasyLocal version and the commit of the program's code, recorded at build time;
- a machine-readable result of a run: instance, seed, configuration (or its hash), cost, effort and time;
- irace's elite configurations as configuration files.
When. Not scheduled: to be designed with the workflow it serves.
Trace micro-benchmarks¶
Why. The tracing overhead benchmark of
easylocal-benchmarks
measures a First Improvement workload, whose events are the core ones; it also
records the visited solutions, with a hash per committed move, and leaves them
out at compile time (trace::without). Tabu search's aspirations, escapes and
tenure changes, and the cost of each event type on its own, are not measured.
What. Micro-benchmarks of the encoding of each event type, the solution hash included, and a Tabu Search workload with each tabu list, under the same recorders as the existing benchmark (null, in-memory, buffered and asynchronous ELTR), reporting the cost per event and per iteration.
When. Not scheduled.
Delta ablation of a neighborhood explorer¶
Why. A problem's delta cost components are where most of its speed comes from, but a delta is not always worth it: the EasyLocal 3 versus EasyLocal 4 benchmarks measure each problem with every delta, with some and with none, and on the assignment problem the run without deltas (which applies each move to a copy and recomputes the cost) beats the one with all of them, whose deltas cost O(jobs) each. Which deltas pay off depends on the problem, the instance and the neighborhood, and today it is found by hand.
What. A benchmarking tool for a problem's own neighborhood explorers: it
evaluates the moves of sampled solutions with every subset of the delta cost
components, from all of them to none, one component at a time (so each delta
is measured against the full evaluation it replaces), checks that every
subset gives the same costs, and reports the time per move of each
configuration and of each delta against none. It would run on the
problem's instances, next to the contract checks of <easylocal/testing.hpp>,
and tell which deltas to keep.
When. Not scheduled.
Parameters described by reflection (C++26)¶
Why. A parameter block describes itself by hand: parameter_schema()
lists each field with its name, a pointer to the member and a description,
next to the field itself. Everything else is generic (the command line,
configuration files and TOML, the interactive tester's parameter windows), but
the list repeats the struct, and a field added without its schema line is
silently not configurable.
What. With C++26 static reflection, the schema would be derived from the
struct: one parameter per data member, named after it, with the description
given as an annotation on the member. A hand-written parameter_schema()
would remain possible, and would take precedence, for names or groupings
that differ from the members. The schema is then the only part of a runner's
configuration still written by hand: a runner already holds its algorithm's
parameters and builds the algorithm from them.
When. When the compilers EasyLocal supports provide reflection. In
October 2026, GCC 16 implements it behind -freflection; GCC 15, Clang 22
and 23 (neither the ^^ operator nor libc++'s <meta>) and Apple Clang do
not. Until all supported compilers have it, reflection could only be an
optional path next to the hand-written schema.