Semantic search tracing¶
Tracing (<easylocal/trace.hpp>) produces typed search events suitable for
trajectory analysis; it is not a progress log, and it is separate from
logging (<easylocal/utils/logging.hpp>), which carries text
messages to an application's logger.
An ordinary run(...) call instantiates a trace::null_tracer. Its
observes<Event> value is false, so event construction and emission are
removed with if constexpr. Applications that need data pass a tracer explicitly
as the trailing run option:
using cost_type = decltype(search)::cost_type; // as a Runner, LocalSearch or Pipeline has
easylocal::trace::memory_recorder<cost_type> trace;
auto result = search.run(initial_solution, easylocal::with(trace));
Core events are emitted by the framework-owned easylocal::search_run, not by
the individual algorithms: start() emits run_started, evaluate_move()
emits move_evaluated, commit() emits move_accepted, finish() emits
run_finished, with the termination reason (preceded by local_optimum when
that is the reason), and random_move() forwards neighborhood_selection events. Every
built-in runner emits incumbent_updated at each new best cost: those that
track a best-so-far solution, such as Simulated Annealing, through
incumbent_updated(), and those whose current solution is the best (Hill
Climbing, First and Best Improvement) through commit_improvement(); custom
events can be sent with run.emit(event).
start(), commit() and evaluate_solution() also emit solution_visited,
with the hash of the solution reached (solution_hash, see
SolutionManager), its cost and
previous_hash, the hash of the solution the move was applied to (0 at the
start of a run and for a solution no move reached, such as a sample of
Pareto Late Acceptance's history): the nodes and the edges of Search
Trajectory Networks and Local Optima Networks. It is emitted only when
the problem has a solution hash, and the hash is computed only when the tracer
observes the event, so the hash costs nothing to a run that does not record
it. The 64-bit hash is a u64 in ELTR and, in JSONL and in the output of
eltr.py, a string of 16 hexadecimal digits ("00000000feedface"): as a JSON
number, JavaScript and jq would round it to a double.
A recorder has a cost type, its cost_type: it observes the core events of
that cost and those without a cost (run_context, neighborhood_selection,
the tabu events, temperature_changed), not the events of a run on another cost, which it would
otherwise drop or convert. A run rejects at compile time a recorder whose
cost_type is not its own; a pipeline gives a stage on another cost (an
until_feasible() stage on the hard cost) the recorder's events without a
cost only. A tracer of your own says with observes<Event>
which events it receives: trace::emit rejects at compile time a tracer
whose observes<Event> is true but whose emit() does not take the event,
such as an emit for another cost type.
Every recorder observes every core event of its cost; a run that builds no
trajectory or local optima network leaves the visited solutions out at
compile time with trace::without, which wraps any tracer and hides the given
event templates from the search:
easylocal::trace::binary_recorder<cost_type> recorder{out};
auto trace = easylocal::trace::without<easylocal::trace::event::solution_visited>(recorder);
runner.run(solution, rng, easylocal::with(trace));
The events without a cost, which are types rather than templates, are hidden
by their type, without<event::neighborhood_selection, event::tabu_escape>(recorder);
a wrapper is itself a tracer, so the two forms combine by wrapping one in the
other.
A run that an exception ends, thrown by a hook of the problem (a cost
component, a move, the Input) or by a tracer, emits no run_finished: the
recorder keeps what it received, a binary recorder writes its pending block
when it is destroyed, and eltr.py --format summary marks the run
"unfinished": true. The exception reaches the caller of run() or
solve() (Solvers).
The solvers emit run_context before each run they start, with the name and
index of the pipeline stage (empty and 0 outside a pipeline) and the attempt,
or the start of a MultiStart, from 0: in a trace of a whole solve, it tells the
runs apart. It carries no cost, so a tracer receives it from the stages that
run on another cost, such as a pipeline's until_feasible() stage on the hard
cost, whose other events a recorder of the full cost does not observe. A run
started outside a solver, with runner.run(...) or a registered runner of an
app, has no run_context.
Tabu search adds aspiration_applied, when the move just
applied was tabu and admitted by the aspiration criterion, tabu_escape,
after the random moves of a reactive list's escape, with the number applied
(the run may stop before the escape ends), and tabu_tenure_changed, with the
previous and the new tenure of a list with one tenure for all its moves, at the
start of the run (previous tenure 0) and at each change; RandomFoo draws its
first tenure at the first move, and traces it then.
Simulated Annealing adds temperature_changed, with the previous and the new
temperature, at the start of the run (previous temperature 0, after a
calibration) and whenever the schedule's temperature changes: a cooling or a
reheat. It reads the temperature only for a tracer that observes the event.
Composite-neighborhood provenance¶
Moves produced by neighborhood_union already carry the originating child in
their variant type. Tracing extracts that information recursively, so nested
unions produce routes such as [0, 2, 1] without adding runtime tags to normal
moves.
Random union sampling can also emit neighborhood_selection events containing:
- the selected child and hierarchical route;
- configured child bias;
- total active bias at that decision;
- conditional branch-selection probability;
- attempt number and whether the selected child produced a move.
This deliberately describes branch selection, not the probability of an individual move: a child neighborhood is not required to sample its own moves uniformly.
The data is intended to support trajectory statistics, Search Trajectory Networks, Local Optima Networks, and later experiments with adaptive neighborhood-sampling policies. Adaptive/RL policies are not part of the current tracing machinery.
Persistence¶
trace::memory_recorder<Cost> owns copies of recorded events. Its neighborhood
routes are copied only when tracing is active. recorder.replay(tracer) emits
the recorded events again, in order, to another tracer: to a streaming recorder
below, it writes the completed trace in that recorder's format.
trace::write_jsonl(out, recorder) is the replay to a jsonl_recorder: one JSON
object per line, as during the run. File naming, directory layout and stream
ownership are left to the application.
For long runs, EasyLocal provides constant-memory streaming recorders.
trace::jsonl_recorder<Cost> writes a human-readable JSONL representation. Its
first line, written at construction, is a header, the same line eltr.py
starts its output with, without the ELTR cost layout: the format version and
the metadata of jsonl_options, whatever tells the run apart, as text.
easylocal::trace::jsonl_recorder<cost_type> trace{
out,
{.metadata = {{"instance", "ta001"}, {"seed", "42"}}}};
// {"event":"trace","version":1,"metadata":{"instance":"ta001","seed":"42"}}
write_jsonl(out, recorder, options) takes the same options.
No event carries a time: a run that does not ask for it reads no clock. For
anytime and time-to-target analyses, a streaming recorder writes timestamps
when its options set timestamps: each core event then ends with
elapsed_ns, the nanoseconds of the steady clock since the recorder was
constructed, a field of the JSONL line or, in ELTR, a u64 field the header
adds to every core event schema. The time of an event within its run is its
elapsed_ns minus that of the run's run_started. Application events carry
no timestamp, and neither does the memory recorder, whose replay happens after
the run.
easylocal::trace::binary_recorder<cost_type> trace{out, {.timestamps = true}};
easylocal::trace::jsonl_recorder<cost_type> lines{json_out, {.timestamps = true}};
ELTR binary stream. It
describes itself: a header gives the metadata of the run, the layout of the
costs and the fields of every event, so a reader needs nothing but the file.
Its records are tagged and length-prefixed, so readers can skip event types
they do not understand.
trace::binary_recorder<Cost> is the synchronous recorder. It encodes events
directly into a contiguous block (256 KiB by default) and writes a block at a
time instead of calling std::ostream for each field. trace::async_binary_recorder<Cost> uses
the same encoder and byte format, but hands completed blocks to one background
writer thread. The producer owns one block while a bounded pool of preallocated
blocks connects it to the writer; if the writer falls behind, the producer
blocks rather than losing events. Event order is therefore deterministic and
tracing is lossless.
Both recorders write and flush the header when they are constructed, so the
file of a run that crashes is still a trace: it decodes up to the last block
written, and loses only the events still buffered, at most a block
(block_size) for the synchronous recorder, the current block and the queued
ones for the asynchronous one. eltr.py reports a file with no bytes at all
as an empty trace.
std::ofstream out{"run-0042.eltrace", std::ios::binary};
easylocal::trace::async_binary_recorder<cost_type> trace{out};
auto result = search.run(initial_solution, easylocal::with(trace));
trace.flush(); // drain the writer and expose I/O failures
if (!trace.good()) { /* handle output failure */ }
An output error of the asynchronous recorder stops the recording, not the
search: from then on emit drops the events, good() is false and flush()
throws std::ios_base::failure, also when the final flush of the stream is
what fails.
Block size and the number of queued blocks are explicit policy knobs, not global state, and the options carry the metadata written in the header: whatever tells the run apart, as text.
easylocal::trace::binary_recorder_options options{
.block_size = 256 * 1024,
.async_queue_blocks = 4,
.metadata = {{"instance", "ta001"}, {"runner", "ts"}, {"seed", "42"}},
};
easylocal::trace::async_binary_recorder<cost_type> trace{out, options};
The asynchronous recorder allocates its queued blocks, and the one the search
fills, at construction: async_queue_blocks is finite, and
easylocal::unlimited throws std::invalid_argument.
The async recorder is deliberately single-producer: it is designed to observe one search trajectory. Synchronization happens at block boundaries, not for every event. The writer thread may reduce search-thread stalls when persistence is slower than encoding, but it does not make I/O free; benchmarks therefore report producer time separately from total drain time.
The memory recorder is intended for tests, short traces and in-process analysis.
JSONL is convenient for inspection and interchange, while ELTR is preferred
when a run may emit millions of events and trace volume matters. Core does not
choose a trace path and does not depend on a JSON, binary-serialization or
logging library.
From the command line¶
cli::run records the run with --trace <file>: JSON Lines for a .jsonl
name, ELTR otherwise, with timestamps, and with the program and every
parameter (the runner that runs, the seed, the instance, the app's parameters)
as metadata. The recorder has the app's cost type: a pipeline stage on
another cost, such as an until_feasible() stage on the hard cost, sends only
its run_context. A file that cannot be written is an error, after the run
when the writes fail. The TextUI and the REST service do not record traces; a
program records one from a Session with session.run(name, with(recorder)).
easylocal_tutorial_cli --instance five.tsp --runner sa --trace sa.eltrace
scripts/eltr.py sa.eltrace --format summary
ELTR layout¶
All integers are little-endian; a string is a u32 size and UTF-8 bytes. A
stream starts with ELTR, the format version as a u32 (1) and the header: its
size (u32), then
- the metadata: a
u32count, then a key and a value string each; - the cost layout: a field list;
- the core event schemas: a
u32count, then each event's tag (u8), name (string) and field list.
A field list is a u32 count, then each field's name (string) and type
(u8): 1 u8, 2 i8, 3 u16, 4 i16, 5 u32, 6 i32, 7 u64, 8 i64,
9 f32, 10 f64, 11 bool, 12 string, 13 bytes (a u32 size and the bytes),
14 route (a u32 count and a u32 per level), 15 cost (the fields of the cost
layout). A scalar cost is one field with an empty name; the fields of a
structured cost are named by their path, hard.0 for the first level of the
hard part.
The records follow, each a tag (u8), its payload size (u32) and the
payload, the fields of its tag's schema in order. Tag 0 is a schema record (the
described tag, name and field list), written before the first record of an
application event that describes itself. The core events are:
| Tag | Event | Fields |
|---|---|---|
| 1 | run_started |
cost |
| 2 | move_evaluated |
evaluations, iterations, current_cost, candidate_cost, neighborhood |
| 3 | move_accepted |
evaluations, iterations, previous_cost, cost, neighborhood |
| 4 | incumbent_updated |
evaluations, iterations, previous_cost, cost |
| 5 | local_optimum |
evaluations, iterations, cost |
| 6 | neighborhood_selection |
attempt, child, bias, active_bias_total, conditional_probability, produced_move, neighborhood |
| 7 | run_finished |
evaluations, iterations, cost, termination (its name, as to_string writes it) |
| 8 | solution_visited |
evaluations, iterations, hash, cost, previous_hash |
| 9 | aspiration_applied |
evaluations, iterations, cost |
| 10 | tabu_escape |
evaluations, iterations, moves |
| 11 | tabu_tenure_changed |
evaluations, iterations, previous_tenure, tenure |
| 12 | run_context |
stage (string), stage_index, attempt |
| 13 | temperature_changed |
evaluations, iterations, previous_temperature (f64), temperature (f64) |
| 128–255 | application events | as their schema says, if they have one |
With timestamps, every core event ends with elapsed_ns (u64).
The counters, the stage index and the attempt are u64, the biases and the probability f64, produced_move a
bool, the neighborhoods routes; the header has the authoritative list.
Decoding ELTR¶
scripts/eltr.py decodes a trace (standard-library Python, python3 or
uv run) from its own description, with no options. By default it writes JSON
Lines: a first trace line with the format version, the metadata and the cost
layout, then the records with the field names of jsonl_recorder, so a decoded
binary trace feeds the same tools as a JSONL one:
scripts/eltr.py run-0042.eltrace > run-0042.jsonl
scripts/eltr.py run-0042.eltrace --events incumbent_updated,run_finished
scripts/eltr.py run-0042.eltrace --format summary # metadata, event counts, runs
scripts/eltr.py run-0042.eltrace --format stn # search trajectory network
scripts/eltr.py run-0042.eltrace --format schema # what the trace records
A cost decodes to a number, or to an object nested as its fields are named,
with lists for numbered levels: {"hard": [0, 2], "soft": 13.5}; NaN and the
infinities become null, as in the JSONL recorder, so the output is strict
JSON. An application
event without a schema is kept as its tag and the hexadecimal payload, and a run
interrupted mid-record is read up to its last whole record with
--allow-truncated. The summary lists the runs in order, each with its initial
and final cost, its effort and its termination, and with its stage, stage index
and attempt when a run_context came before it. As a module, eltr.Trace(stream) reads the header
(metadata, cost_fields, schemas) and iterates over the records.
The stn format builds the network from the solution_visited events, which
are recorded when the problem has a solution hash:
one node per distinct hash, with its cost, its number of visits and the runs
that visit it, and one edge per move, from its previous_hash to its hash,
with its count and runs; a visit no move reached has no edge, so an algorithm
that keeps several solutions gets the right edges. A run is its index in the
trace, from 0, as the summary lists it (with its stage and attempt), and
starts and ends give the first and the last solution each run visits: the
network of a whole solve, or of several runs in one file, keeps them apart.
Extending ELTR without touching EasyLocal¶
The binary encoder is intentionally small enough to customize in application
code. The default cost writer, default_binary_cost_writer<Cost>, encodes an
arithmetic cost, a cost::lexicographic, a cost::pareto and a
cost::hierarchical, nested as they are. Another cost needs a writer, which receives a binary_record_writer,
whose primitive operations (u8, i8, u16, i16, u32, i32, u64,
i64, f32, f64, boolean, bytes, string and route) always use the
ELTR representation, and describes what it writes with fields(), for the
header. The writer does not calculate payload sizes and does not know about
block or thread management:
struct cost_binary
{
void operator()(
easylocal::trace::binary_record_writer& out,
const Cost& cost) const
{
out.i64(cost.hard_value());
out.i64(cost.soft_value());
}
static std::vector<easylocal::trace::binary_field> fields()
{
using enum easylocal::trace::binary_type;
return {{"hard", i64}, {"soft", i64}};
}
};
easylocal::trace::binary_recorder<Cost, cost_binary> trace{out, cost_binary{}};
Applications can also add their own semantic event types through ADL. Tags
1..127 are reserved by EasyLocal; user_binary_event_tag<N>() maps application
indices 0..127 onto tags 128..255:
namespace my_problem
{
struct weight_changed
{
std::uint64_t iteration;
double weight;
};
constexpr std::uint8_t binary_event_tag(const weight_changed&)
{
return easylocal::trace::user_binary_event_tag<0>();
}
void encode_binary_event(
easylocal::trace::binary_record_writer& out,
const weight_changed& value)
{
out.u64(value.iteration);
out.f64(value.weight);
}
// Optional: the schema, written before the first record of the tag.
easylocal::trace::binary_event_schema describe_binary_event(
std::type_identity<weight_changed>)
{
using enum easylocal::trace::binary_type;
return {"weight_changed", {{"iteration", u64}, {"weight", f64}}};
}
} // namespace my_problem
No EasyLocal specialization is required. A binary recorder automatically
advertises observes<my_problem::weight_changed> and the ordinary
trace::emit(...) path can emit it. Application events are ELTR records
only: the memory and JSONL recorders, which have no encoding for them, do not
observe them. This keeps problem-specific instrumentation
close to the problem code while the recorder owns buffering and persistence.
Tag ownership is application-level metadata; independent extensions should
coordinate their user-tag assignments when they share a trace schema.
A cost writer or an encode_binary_event that throws leaves no part of its
record in the trace: the binary recorders drop the whole record (and the schema
written before it, which the next record of the tag writes again), and the
JSONL recorder formats a line before writing it at once. The exception reaches
the caller of emit.
The default JSON cost writer, default_json_cost_writer, writes the costs
the default ELTR writer encodes, in the shape eltr.py decodes them to: a
number as the shortest text that reads back to the same value, NaN and the
infinities, which JSON has no numbers for, as null, a cost::lexicographic
or a cost::pareto as the array of its levels and a cost::hierarchical as
{"hard": ..., "soft": ...}, nested as the types are: {"hard": [0, 2],
"soft": 13.5}. Another cost needs a small compile-time writer, which owns the
JSON representation and adds no virtual dispatch:
struct cost_json
{
void operator()(std::ostream& out, const Cost& cost) const
{
out << "{\"hard\":" << cost.hard_value()
<< ",\"soft\":" << cost.soft_value() << '}';
}
};
easylocal::trace::jsonl_recorder<Cost, cost_json> trace{out, cost_json{}};
The same writer can be passed as the third argument to write_jsonl(...) when a
memory_recorder is serialized after the run.
Overhead benchmark¶
The tracing overhead is measured in
easylocal-benchmarks
(infrastructure/) at every release, and shown on the
Benchmarks page. easylocal_trace_benchmark compares the same
First Improvement workload under:
- ordinary baseline execution;
- explicit
null_tracer; - counter-only tracing;
- the full in-memory recorder;
- block-buffered ELTR binary recording to a discard stream;
- asynchronous ELTR recording, with producer and total drain time reported separately;
- synchronous and asynchronous ELTR recording to temporary files.
JSONL is intentionally not part of the timing comparison. Its formatting cost is a property of the textual serialization policy and obscures the lower-level trace boundary that this regression probe is intended to track. JSONL functionality is still covered by deterministic correctness tests.
The benchmark checksum consumes the final cost, termination reason, evaluation
count, and solution contents. This prevents optimizer dead-code elimination from
making tracer specializations incomparable. Temporary-file modes flush the C++
stream but deliberately do not call fsync; they measure cost visible to the
search process, including OS page-cache interaction, rather than a durability
guarantee.
A second target, easylocal_trace_cost_encoding_benchmark, isolates ELTR cost
encoding using equivalent move_evaluated events for three representative cost
shapes: scalar int64, a two-component lexicographic cost, and a hierarchical
cost containing that hard lexicographic branch plus one soft component. It
reports nanoseconds and serialized bytes per event.
These numbers are regression diagnostics, not cross-machine performance promises.