Diagnostics Foundation
The diagnostics layer provides opt-in, compile-time-gated counters for path
search, allocation tracking, and queued phase execution. It lives in
include/tess/diagnostics/diagnostics.h and is exported by tess/tess.h.
Public Surface
TESS_ENABLE_DIAGNOSTICSis the compile-time gate. When it is defined,TESS_DIAGNOSTICS_ENABLEDis1and the counter types below exist; when it is not defined,TESS_DIAGNOSTICS_ENABLEDis0, every diagnostic macro expands to an empty statement, and the counter types are not declared at all.- Event macros keep instrumentation out of release builds:
TESS_DIAGNOSTIC_ONLY(expr)runs an expression only when enabled,TESS_DIAGNOSTIC_INC(counter)andTESS_DIAGNOSTIC_ADD(counter, value)bump caller-visible counters, andTESS_DIAG_EVENT(name)/TESS_DIAG_EVENT_VALUE(name, value)call the matchingtess::diagnostics::event_<name>hook. PathCountersrecords path-search internals: scratch clears (event_path_clear), initializations (event_path_initialize), start/goal passability checks (event_path_start_passability_check,event_path_goal_passability_check), heap pushes and pops (event_path_heap_push,event_path_heap_pop), stale and closed pops (event_path_skip_pop), neighbor candidates, passability checks, cost reads, blocked and closed neighbors (event_path_neighbor_candidate,event_path_passability_check,event_path_cost_read,event_path_neighbor_blocked,event_path_neighbor_closed), relax attempts and successes (event_path_relax_attempt,event_path_relax_success), touched nodes (event_path_touch_node), heuristic calls (event_path_heuristic), and reconstructed nodes (event_path_reconstruct_node).AllocationCountersrecords allocation and deallocation counts and bytes throughrecord_allocation(size)andrecord_deallocation(size).QueuedPhaseCountersrecords queued phase execution: validated phase calls and operations (event_queued_phase_execute), invalid phase tokens (event_queued_phase_invalid_range), phase failures (event_queued_phase_failure), partitioned phase calls and dirty partitions (event_queued_partitioned_phase), scoped-thread dispatches and worker counts (event_queued_scoped_thread_dispatch), worker-pool dispatches and worker counts (event_queued_worker_pool_dispatch), and collected dirty records and merged dirty chunks (event_queued_dirty_collect,event_queued_dirty_merge). Exceptional coalescing reports both quantities separately, so duplicate records count toward collection while each affected chunk counts once toward merge.ScopedPathCounters,ScopedAllocationCounters, andScopedQueuedPhaseCountersare RAII scopes that install a caller-owned counter struct as the active sink for the current thread and restore the previous sink on destruction. They are non-copyable, and each counter struct has areset()helper.
Behavior
Event hooks are no-ops unless a matching scoped counter object is active on the calling thread; installing a scope is the only way to start recording. Scopes nest: the innermost active scope receives events, and destroying it restores the outer scope.
The active counter sinks are thread_local pointers. This is a deliberate
scope limitation: a counter scope installed on one thread observes only
events raised by that thread, so counters do not aggregate across worker
threads. ScopedThreadPhaseExecutor in the queued layer, for example,
records its dispatch counts on the caller thread before launching workers,
and worker callbacks do not mutate the caller's queued-phase counters. A
future worker-pool backend needs per-worker sinks plus explicit reduction
before cross-thread totals can be trusted.
Because the counter types only exist when TESS_ENABLE_DIAGNOSTICS is
defined, code that declares counter objects or scopes must itself be
guarded (tests use dedicated diagnostics-enabled targets). Instrumentation
sites in library code use only the macros, which compile away cleanly in
non-diagnostic builds.
Warning Sink
include/tess/diagnostics/warning_sink.h adds an opt-in channel for
structured warnings, gated by the same TESS_ENABLE_DIAGNOSTICS switch (the
types do not exist when it is undefined).
Warningis a non-owning record: aWarningCategoryorigin tag, astd::string_view message, a numericdetail, and astd::source_location wherethat defaults to the construction site. As withPathView, themessagemust reference storage that outlives every sink that retains the warning (string literals or other static storage); a sink copies the record by value but never the pointed-to characters. This precondition is not compiler-enforceable.WarningSinkis a concept: any type with anoexcept warn(const Warning&).NullWarningSinkdiscards every warning and is the zero-cost default for a parameter that must satisfy the concept.BufferedWarningSink<Capacity>is a caller-owned fixed-capacity ring with inlinestd::arraystorage, sowarn()never allocates. When full it overwrites the oldest warning and counts the loss indropped(); indexing is oldest-first (operator[](0)is the oldest retained warning).clear()resets the window and the dropped count.
No tess library code raises warnings yet; the sink is a foundational primitive for later stages (queued-ops result reasons, scheduler budgets).
Trace Buffer and Timers
include/tess/diagnostics/trace.h adds a structured event log and timing
capture, gated by the same TESS_ENABLE_DIAGNOSTICS switch.
TraceCategoryis a coarse origin tag (General,Path,Topology,Queued,Planner,Scheduler,Render);Countis a sentinel used to size the per-category timing array and must not be recorded against.trace_category_countis the corresponding public array-bound constant.TraceRecordis one structured point: a category, a non-owningstd::string_view label(same static-storage contract asWarning::messageandPathView), avaluedatum, and a monotonicsequenceordinal.TraceBufferis caller-owned. It wraps astd::span<TraceRecord>the caller supplies (which must outlive the buffer and any scope targeting it) and holds an inline per-categoryTraceCategoryStatsaccumulator, so nothing here allocates.record()appends to the ring (overwriting oldest, countingdropped(), keeping sequence gaps visible); an empty span is valid and drops every record.record_timing()folds a nanosecond sample into a category's accumulator (samples,total_ns,min_ns,max_ns; the first sample sets both min and max; out-of-range categories are ignored).total_nswraps only after ~584 years of accumulated time and is treated as unbounded.ScopedTraceinstalls aTraceBufferas the thread's active buffer with the same nestable, non-copyable RAII pattern as the counter scopes;trace_eventand theTESS_DIAG_TRACE/TESS_DIAG_TRACE_VALUEmacros route to it (and compile to nothing when diagnostics are off). Worker threads do not feed the installer's buffer -- the same deliberatethread_locallimit as the counters.ScopedTimeris a wall-clock (steady_clock) RAII timer. It binds to the buffer active at construction, so a timer started outside anyScopedTracerecords nothing even if a buffer is installed before it ends, and nested scopes attribute timing to the buffer that was active when the span began. On destruction it folds the elapsed nanoseconds into the category's timing accumulator and appends a record whosevalueis that duration.
Planner Trace
The queued-ops planner (ops/queued.h) records its per-operation decisions to
the active trace buffer under the Planner category, using the
TESS_DIAG_TRACE_VALUE macro so the instrumentation compiles away when
diagnostics are off. Each record's value is the operation (or phase) index:
plan_operationsemitsinvalid_identity,invalid_write_policy,invalid_field_access,invalid_domain,conflict(a field hazard against an earlier operation), orplanned(accepted) for each operation.plan_parallel_execution_phasesemitsunsupported_write_policy,new_phase(an operation that opens a new parallel phase, whether the first or one forced by a conflict), ormerged(an operation folded into the current phase).
This is the first library code to feed the trace buffer; a consumer installs a
ScopedTrace around a plan call to capture the decision log.
Snapshot Export
include/tess/diagnostics/export.h provides plain, self-contained snapshot
structs so a panel or consumer can hold diagnostics without touching the live
sinks. TimingSnapshot copies every category's TraceCategoryStats out of a
TraceBuffer (with a Count-guarding category() accessor); DiagnosticsSnapshot
bundles copies of the PathCounters, AllocationCounters, and
QueuedPhaseCounters a caller owns alongside a TimingSnapshot. capture_timing
and capture_diagnostics assemble them as pure copies, so a snapshot outlives
its sources unchanged.
ImGui Panels (opt-in)
include/tess/debug/imgui/panels.h provides reference Dear ImGui panels over
the export snapshots. It is doubly gated -- the body exists only when the
consumer defines both TESS_ENABLE_IMGUI and TESS_ENABLE_DIAGNOSTICS on its
own target -- and tess core never fetches or links Dear ImGui. tess.h does
not include it.
- The consumer must include
<imgui.h>beforepanels.h; the header emits a#errorifIMGUI_VERSIONis undefined when both gates are on, so a misordered include fails loudly instead of with name-lookup errors. - The panels use only the three most stable ImGui text primitives (
Text,TextUnformatted,Separator) and printuint64values throughunsigned long longcasts for portable printf-style formatting, so they compile across ImGui versions. draw_timing_panel(TimingSnapshot)renders the per-category timing table;draw_path_counters_panel,draw_queued_counters_panel, anddraw_allocation_counters_panelrender their counter structs; anddraw_diagnostics_panel(DiagnosticsSnapshot)draws every section in order.category_name(TraceCategory)maps a category to a label for custom panels.
tess validates the header in CI against a minimal ImGui stub
(tests/imgui_stub/imgui.h, tess_diagnostics_panels_test); the real Dear
ImGui build is exercised by a downstream consumer.
Deliberate Limits
Beyond the counters, warning sink, trace/timing, planner trace, snapshot export, and the opt-in ImGui panels above, this layer does not yet implement a sampling profiler, cross-thread aggregation, or any runtime toggle; enabling or disabling diagnostics is a recompile.