Skip to content

8. Writing your own runner

To try a search strategy of your own, write a class with a run member. The framework supplies evaluation, counters and run control, leaving you to decide which moves to accept. This example samples random moves and keeps improvements:

struct RandomDescentParameters
{
    easylocal::parameter<easylocal::limit, "max_evaluations"> max_evaluations{
        1000}; // a count, or easylocal::unlimited

    // The schema makes the parameters configurable (chapter 10).
    static consteval auto parameter_schema()
    {
        return easylocal::config::fields(
            easylocal::config::field<&RandomDescentParameters::max_evaluations>(
                "Evaluation budget",
                easylocal::config::range(1, easylocal::unlimited)));
    }

    easylocal::config::validation_result validate() const
    {
        return easylocal::config::check_schema(*this);
    }
};

class RandomDescent : public easylocal::parameters_base<RandomDescentParameters>
{
public:
    using parameters_base::parameters_base; // configurable, built from the block

    // The result type is the one run.finish() returns: auto deduces it.
    template<class Run, std::uniform_random_bit_generator RNG>
    auto run(Run& run, Run::solution_type solution, RNG& rng) const
    {
        run.limit_evaluations(parameters().max_evaluations);
        auto current = run.start(solution); // evaluates, emits run_started

        // should_stop: cancelled, the budget spent, the target reached or the
        // time up.
        while (!run.should_stop())
        {
            auto move = run.random_move(solution, rng);
            if (!move) // no move at all: a local optimum
                return run.finish(
                    std::move(solution),
                    current.cost(),
                    easylocal::termination_reason::local_optimum);

            run.next_iteration();
            auto candidate = run.evaluate_move(solution, current, *move);
            // A descent only ever improves: every commit is a new incumbent,
            // which commit_improvement() reports to the trace.
            if (run.better(candidate.cost(), current.cost()))
                run.commit_improvement(solution, current, std::move(candidate), *move);
        }
        return run.finish(std::move(solution), current.cost()); // run_finished
    }
};

The bound runner passes an easylocal::search_run to run. It provides comparison and neighborhood access, and handles the work shared by algorithms:

search_run member Effect
limit_evaluations(n) evaluation budget, including the initial evaluation: a count, or easylocal::unlimited
start(solution) first evaluation; emits run_started, reports progress
should_stop() true on cancellation, a reached target cost, an exhausted evaluation budget or a passed time limit; records the reason
moves(solution), random_move(solution, rng) neighborhood access
evaluate_move(solution, current, move) counts, emits move_evaluated, reports progress
commit(solution, current, candidate, move) applies the move, emits move_accepted
commit_improvement(solution, current, candidate, move) applies the move, emits move_accepted and incumbent_updated, for an algorithm whose current solution is its best
next_iteration() advances the iteration counter
incumbent_updated(previous, cost) for algorithms that keep a best-so-far solution
finish(solution, cost[, reason]) emits run_finished, returns a search_result

Check should_stop() in the loop to honour cancellation and run limits. Keep per-run state in local variables so run can be const and the algorithm can be reused.

In a descent, the current solution is also the best found. Use commit_improvement() to apply a move and report the new incumbent. Algorithms that accept worsening moves, such as Simulated Annealing, use commit() and track the best solution separately with best_so_far.

The runner is used like a built-in one:

auto descent =
    el::make_runner<RandomDescent>(RandomDescentParameters{.max_evaluations = 200})
    | sm | nhe;

parameters_base<RandomDescentParameters> declares parameters_type, stores the parameters and exposes them through parameters(). The runner keeps this block and constructs the algorithm from it when bound.

Adding parameter_schema() and validate() to the block (chapter 10) also exposes it to configuration files and frontends. Standalone runners use paths under search.*; apps use runners.<name>.* (chapter 12). Your custom algorithm then gets the same configuration support as the built-in ones.

An algorithm whose parameters_type is not a parameter block can still run, but frontends cannot edit its settings. check(app, ...) reports this gap (chapter 15).

See also

  • Runners: the complete search_run interface and its escape hatches.

Next steps

Chapter 9 lets the framework build the initial solution.

For a worked extension of an existing algorithm, the advanced Simulated Annealing chapter reads the library's search loop and implements a custom reheating policy.