Skip to content

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, a run_control for 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_aborted core event, emitted by search_run when an exception leaves the run, with the counters and the cost reached, before the exception goes on;
  • a solve_failed exception 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.

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.

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 const members 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), answers probe(move) without committing and commit(move) when the explorer applies one. A solution that carried its network would pay for it at every copy, which is what sank AutoState;
  • 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 = value format that --config reads 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.