From 4564029118b4758836d82d30cccee1a230d3f7c2 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Thu, 24 Sep 2026 12:25:23 +0200 Subject: [PATCH 1/5] [M1-ALLOC-01] Zero sim-loop allocation assertion (G-R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The allocation watch (new public header src/laige-core/include/laige/alloc_watch.h + src/laige-core/alloc_watch.cpp): a process-wide heap-allocation counter behind strong global operator new/new[] (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (LAIGE_ALLOC_WATCH=1 PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with allocWatchLive() false — the established fallback: the leak-free sanitizer run + the pool reservation delta). Armed-window model: allocWatchArm() = three relaxed stores (first-site, count, armed flag); allocWatchRead() = two relaxed loads returning AllocWatchReading{allocs, firstSite}. The first offending call site is the allocating call's own return address (__builtin_return_address(0) on GCC/Clang, __return_address on MSVC), evaluated in the operator-new frame; the first offender wins via a relaxed CAS that fails once recorded. - The attribution contract (laige::detail::LoggingAllocationGuard): the logging facade's emit path (the LAIGE_LOG macro block + Logger::record) marks its own heap work — the field value strings, the rate-state, the sink's message formatting — so it is not attributed to the sim loop's G-R1 window. The engine's documented in-tick degradations (a G-R5 budget_overrun/budget_critical, a replay record_failed, a guardrail warn) still log (NFR-13.3, rate-limited, actionable) and never trip G-R1; any other in-tick allocation (a system's local std::vector, engine storage growth) still fails at its call site. - The per-tick check (GameLoop::runOneTick, #if !NDEBUG): arm BEFORE the tick body (beginFrame + runSystems + attached profiler + onTick hook + replay recorder), read AFTER a completed tick (status.ok() — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract). A nonzero count logs one alloc/sim_tick_allocation Error event (fields tick/allocs/site) and then fails the debug assert (FR-12.3: actionable, never silent). Release builds compile the whole check out (CPP-012): an allocating tick degrades through the already-logged pool accounting + the per-frame simAllocs delta. The check is the standing hot-path guardrail for every later sim/render step (roadmap README §6). - Tests: the new ZeroAlloc suite + zero_alloc CTest entry (TSan property list): the 10k-entity M1-ECS-07 workload through the GameLoop (700 direct warm-up ticks bring every archetype to its high water before the window; then 10k ticks at 60 Hz on the synthetic clock, kTickNs = 16666667 = ceil(1e9/60)) — per-tick window reads 0 allocs, reservation delta 0, rows/entity invariants, FNV-1a visit checksum, machine-greppable zero-alloc window line; a scratch system with a deliberate std::vector fails the tick assert (forked SIGABRT child, POSIX; GTEST_SKIP on Windows; release/sanitizer branch runs 5 clean ticks); the watch's first-site capture checked directly. The test-side counter shim moves to laige-core (tests/**/logging_alloc_counter.h wraps the watch; the LAIGE_ALLOC_COUNTER test define is gated on the same trees as LAIGE_ALLOC_WATCH). HeadlessFramePathAllocatesNothing (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm). - Docs in the same change: docs/api/alloc_watch.md (new — window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + game_loop.md (the zero-allocation section + the Performance cost line) + profiler.md cross-ref + the docs/README index; laige-api.json regenerated (820 symbols from 25 headers, api-real-tree green). - Local Verify: ctest -R zero_alloc green; the full canonical ctest 92/92 and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang; zero warnings on every tree; the determinism + include lints OK. --- docs/README.md | 5 + docs/api/alloc_watch.md | 204 ++++++ docs/api/game_loop.md | 46 +- docs/api/profiler.md | 8 +- laige-api.json | 236 +++---- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 5 +- src/laige-core/CMakeLists.txt | 34 +- src/laige-core/alloc_watch.cpp | 172 ++++++ src/laige-core/include/laige/alloc_watch.h | 188 ++++++ src/laige-core/include/laige/logging.h | 19 +- src/laige-core/logging.cpp | 9 + src/laige-sim/game_loop.cpp | 83 ++- src/laige-sim/include/laige/sim/game_loop.h | 46 ++ tests/laige-core/CMakeLists.txt | 22 +- tests/laige-core/logging_alloc_counter.cpp | 74 --- tests/laige-core/logging_alloc_counter.h | 57 +- tests/laige-sim/CMakeLists.txt | 72 ++- tests/laige-sim/engine_tests.cpp | 24 +- tests/laige-sim/logging_alloc_counter.cpp | 79 --- tests/laige-sim/logging_alloc_counter.h | 63 +- tests/laige-sim/zero_alloc_tests.cpp | 650 ++++++++++++++++++++ 22 files changed, 1704 insertions(+), 394 deletions(-) create mode 100644 docs/api/alloc_watch.md create mode 100644 src/laige-core/alloc_watch.cpp create mode 100644 src/laige-core/include/laige/alloc_watch.h delete mode 100644 tests/laige-core/logging_alloc_counter.cpp delete mode 100644 tests/laige-sim/logging_alloc_counter.cpp create mode 100644 tests/laige-sim/zero_alloc_tests.cpp diff --git a/docs/README.md b/docs/README.md index aa36271..73e565a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -160,6 +160,11 @@ still to land. flags (M0-CORE-03/04; ADR 0002). - [Memory pools](api/pools.md) — `ArenaPool` and `Pool` with generation-checked handles and `PoolStats` accounting (M0-CORE-05). +- [Allocation watch](api/alloc_watch.md) — the process-wide heap + allocation counter behind the G-R1 zero-sim-loop-allocation + guardrail: the armed-window model, the per-tick debug assertion + (game_loop.md), the release fallback, and the sanitizer scope + (M1-ALLOC-01). - [Bounded JSON](api/json.md) — `laige::JsonValue`, `parseJson`, `serializeJson`, `JsonOptions` bounds (M0-CORE-07; ADR 0003). - [Budget harness](api/budget_harness.md) — `Histogram`, `TimeIt`, diff --git a/docs/api/alloc_watch.md b/docs/api/alloc_watch.md new file mode 100644 index 0000000..cd203f7 --- /dev/null +++ b/docs/api/alloc_watch.md @@ -0,0 +1,204 @@ +# Allocation watch (`laige::allocWatch*`) + +The process-wide heap-allocation counter behind the G-R1 +zero-allocation guardrail (M1-ALLOC-01; PRD §8.1, §9.3, `budgets.json` +`sim_heap_allocs` target 0, AGENTS PERF-003, CORE-001, FR-12.3). +Public header: `src/laige-core/include/laige/alloc_watch.h`; +counting backend: `src/laige-core/alloc_watch.cpp` (compiled in every +non-sanitizer build tree — the `LAIGE_ALLOC_WATCH=1` public definition +marks exactly those trees). Unit suite: `ctest -R zero_alloc` +(`tests/laige-sim/zero_alloc_tests.cpp`); the engine-side consumer is +the per-tick check in +[api/game_loop.md](game_loop.md#the-zero-allocation-check-m1-alloc-01-g-r1). + +G-R1: the simulation's steady-state tick path MUST allocate nothing. +The budgets table (PRD §9.3) names the enforcement: **"Debug: +allocation counter + assert; Release: pool overflow → logged +degradation"**. This header is the debug side; the release side +already exists (the pool overflow path, [api/pools.md](pools.md); +the per-frame `simAllocs` delta, [api/profiler.md](profiler.md)) — +nothing new ships there. + +## The armed-window model + +The watch has one process-wide state: an **armed window**. + +```cpp +laige::allocWatchArm(); // start a fresh window +// ... the region under test (one tick, one loop run, one API call) ... +laige::AllocWatchReading r = laige::allocWatchRead(); +// r.allocs heap allocations since the arm +// r.firstSite the call site of the FIRST offending allocation +// (nullptr while none) +``` + +- `allocWatchArm()` resets the window's count and clears the first + site, then arms. One armed window at a time; a window stays live + until the next arm. O(1), no allocation. +- `allocWatchRead()` reads the count plus the first offending call + site. O(1), no allocation. +- `allocWatchLive()` is true in every tree where the counting backend + is compiled in (every non-sanitizer tree); false in the sanitizer + trees, where the watch degrades to inline no-ops (see the scope + section). + +**First-site semantics:** the first allocation after the arm is +recorded (the caller's return address of the allocating call — +`__builtin_return_address` on GCC/Clang, `__return_address` on MSVC); +later offenders are counted but keep the first site (a relaxed CAS +that fails once the first site is recorded). The first site is the +actionable one: it is where the invariant broke first (FR-12.3). + +## The per-tick assertion (the engine consumer) + +In **debug builds**, `GameLoop::runOneTick()` +([api/game_loop.md](game_loop.md)) arms a fresh window **before** the +tick body and reads it **after a completed tick**. A completed tick +with a nonzero window count fails with: + +| Condition | Event | Severity | +|---|---|---| +| a completed tick allocated (debug only) | `alloc/sim_tick_allocation` | Error | + +Event fields: `tick`, `allocs` (the window's count), `site` (the first +offending call site, hex). The event fires once per offending tick +(the rate limiter never suppresses the first event of an episode — +and the assert follows it immediately, so a second event never +matters), then the debug assert breaks the build run with the event's +fix text in the condition string. + +The window covers **everything the tick runs that is the sim +loop's own work**: the systems, the `onTick` hook, the replay +recorder, engine storage growth (archetype column doublings, table +growth), and any other heap use of the tick (a system's local +`std::vector`, a pool's backing store). A **failed** tick is not +checked (the profiler's "a failed tick is not recorded" contract, +api/profiler.md): a tick whose systems did not complete ran no user +work to blame, and its validation error is already actionable. + +**Attribution — what is NOT the sim loop's heap:** G-R1's budget is +`sim_heap_allocs` (budgets.json) — the sim loop's own heap: storage, +systems, pools. The diagnostic subsystem's memory is a separate +subsystem with its own contract (LOG-003 gates hot-path logging by +construction; LOG-004 rate-limits repeated failures), so the +logging facade's emit path wraps its work in +`laige::detail::LoggingAllocationGuard` (the `LAIGE_LOG` macro; +`Logger::record`): the field value strings, the rate-state, and the +sink's message formatting are attributed to the diagnostic +subsystem, not the tick's window. Consequence: the engine's +documented in-tick degradations — a G-R5 budget-overrun +warn/critical, a replay write failure, a guardrail warn — still log +(actionable, rate-limited) and never trip G-R1; a tick that +allocates for any other reason still fails at its call site. + +This is the **standing hot-path guardrail for every later sim/render +step** (roadmap README §6, "Global invariants"): any M2/M3 step that +adds an allocation inside a tick — a new system, a new recorder, a +new pool's backing store — fails the debug build immediately, at the +allocating call site, instead of silently eroding the 0-allocs +budget. + +## Release builds + +Release compiles the entire check out (the arm/read and the assert — +`#if !NDEBUG`): no assertion, no crash (CPP-012). A game system that +allocates in release degrades through the already-logged pool +accounting (pool overflow, pools.md) and shows up in the per-frame +`simAllocs` delta (M1-PROF-01/02 frame report) — never a silent +success, never an assert. The counting backend itself is still +compiled in (it is not `NDEBUG`-gated), so release tools and tests +can read the watch; only the tick-boundary enforcement is +debug-only. + +## Scope of the counting backend + +The backend is a **strong definition of the global +`operator new`/`new[]`** (plus the nothrow and sized-deallocation +variants) in `laige-core`, linked ahead of the CRT's weak defaults: + +- **Static build trees (the default, every P0 OS):** the overrides sit + in the executable's link — an armed window sees **every** heap + allocation in the process: the engine, the pools, game systems, + test frameworks. +- **Shared build trees:** the overrides live inside the laige-core + image. On POSIX, dynamic linking interposes them process-wide (an + executable's `operator new` call resolves to the library's + definition); on Windows there is no cross-image interposition, so an + armed window sees the allocations made **inside the engine images** + — the engine allocators and the pools, which is the sim loop's + storage — but not allocations made in the executable itself. The + canonical (static) trees give full process coverage everywhere. +- **Sanitizer trees (`LAIGE_ASAN` / `LAIGE_TSAN`):** the sanitizer + runtimes own `operator new`/`delete`, so the counting backend is + **not** compiled in and the header degrades to inline no-ops + (`allocWatchLive()` is false; arm/read return zero). There, the + zero-allocation property is verified by the leak-free sanitizer run + of the same loop plus the pool reservation-delta assertion — the + established fallback pattern (tests/laige-core, M0-CORE-02/05; + tests/laige-sim, M1-ECS-03/07; the `LAIGE_ALLOC_COUNTER` test + definition gates the test-side probes on the same trees). + +The `LAIGE_ALLOC_WATCH=1` public compile definition is set on +laige-core in exactly the trees where the backend is compiled in, so +every consumer (the sim module, tools, test executables) sees the same +live state. The test-side `LAIGE_ALLOC_COUNTER` definition +(`tests/**/logging_alloc_counter.h`) uses the same gate, so the test +probes and the engine's assertion always agree about whether the +watch is live. + +## Cost (PERF-003, DBG-004) + +| Path | Cost | +|---|---| +| Per allocation, window **disarmed** | one logging-depth load + one armed-flag load + two branches (no counter traffic) | +| Per allocation, window **armed**, outside a diagnostic emit | one logging-depth load, one armed-flag load, one `fetch_add`, one CAS that fails once the first site is recorded | +| Per allocation, inside a diagnostic emit (the attribution contract) | one logging-depth load + one branch (the emission's own work, not counted) | +| Per completed tick, debug builds | one arm (three atomic stores: first-site, count, armed flag) + one read (two atomic loads) | +| Release builds | the tick check is compiled out; the disarmed allocation path remains | + +No allocation, no logging, no lock on any healthy path (LOG-003, +DBG-004). Windows are short and allocation-free by contract, so the +armed path's extra atomics are paid only on the rare allocation that +should not happen. + +## Threading (CONC-001) + +The armed window has exactly one owner: the **sim owner thread** +(the simulation is single-threaded, PRD §10.2). The loop's tick path +is the only caller that arms, so windows never nest. The counters are +relaxed atomics: an allocation from another thread during an armed +window is counted (it happened during the tick — the diagnostic says +so) but it is **observation data, not simulation state** (ARCH-009): +it never enters the tick count, the state hash, or a replay. + +## Misuse warnings + +- **Arming from more than one owner** breaks the single-window + contract (the two owners' windows interleave; the reads are + meaningless). The only intended armer is the per-tick check; + tests arm deliberately and single-threaded. +- **Reading a disarmed window** returns whatever the last window + held (the count is not reset on disarm — disarming is not an + operation; the window ends by the next arm). Arm before the region + you measure. +- **Trusting the count in a shared Windows build:** see the scope + section — outside-image allocations are invisible there. +- **Expecting the watch in sanitizer trees:** it is a no-op by + design (the runtimes own the allocators); use the leak-free + sanitizer run + the reservation delta there. + +## Example + +```cpp +laige::allocWatchArm(); +runOneSimTick(); // must allocate nothing (G-R1) +laige::AllocWatchReading r = laige::allocWatchRead(); +// r.allocs == 0, r.firstSite == nullptr — otherwise the tick broke +// G-R1 and the engine's per-tick check has already logged +// alloc/sim_tick_allocation with the first offending site. +``` + +The engine's per-tick check is exactly this shape, repeated around +every completed tick in debug builds ([api/game_loop.md](game_loop.md)); +the test-side probes wrap the same API +(`tests/**/logging_alloc_counter.h`). diff --git a/docs/api/game_loop.md b/docs/api/game_loop.md index ff5639c..31833bc 100644 --- a/docs/api/game_loop.md +++ b/docs/api/game_loop.md @@ -208,6 +208,45 @@ tick is not counted and not recorded (the tick-count contract, (ARCH-009) — it never enters the tick count, the state hash, or a replay. +## The zero-allocation check (M1-ALLOC-01, G-R1) + +**Debug builds only.** G-R1 (PRD §9.3): the simulation's steady-state +tick path MUST allocate nothing — the `sim_heap_allocs` budget +(budgets.json) targets 0 allocs/frame. `runOneTick` enforces it +directly, per tick: + +- **Arm** — `laige::allocWatchArm()` (the process-wide allocation + watch, [api/alloc_watch.md](alloc_watch.md)) starts a fresh window + **before** the tick body (the `beginFrame` + `runSystems` dispatch, + plus the attached profiler, `onTick` hook, and replay recorder). +- **Read + assert** — **after a completed tick** (`status.ok()`), + `laige::allocWatchRead()`: a nonzero count logs one + `alloc/sim_tick_allocation` Error event (fields: `tick`, `allocs`, + `site` — the first offending call site) and then fails the debug + assert (FR-12.3: actionable, never silent). A failed tick is not + checked (the profiler's "a failed tick is not recorded" contract). +- **Attribution** — the window counts the sim loop's own heap: the + systems, the `onTick` hook, the replay recorder, engine storage + growth, any other tick heap use. The diagnostic subsystem's own + emit (the logging facade's field strings, rate-state, sink + formatting — see [api/alloc_watch.md](alloc_watch.md)) is + attributed to the diagnostic subsystem: the engine's documented + in-tick degradations (a G-R5 budget overrun, a replay write + failure, a guardrail warn) still log and never trip G-R1. +- **Release builds:** the whole check is compiled out (`#if !NDEBUG`) + — no assert, no crash (CPP-012); an allocating tick degrades + through the logged pool accounting and the per-frame `simAllocs` + delta ([api/profiler.md](profiler.md)) instead. +- **Standing guardrail** — this check is the hot-path guardrail for + every later sim/render step (roadmap README §6, "Global + invariants"): any M2/M3 step that adds an allocation inside a tick + fails the debug build at the allocating call site. + +Suite: `ctest -R zero_alloc` (the 10k-entity M1-ECS-07 workload +through the loop does 0 allocs per tick; a deliberate `std::vector` in +a scratch system fails the assert; the watch's first-site capture is +checked directly). + ## Performance (DOC-004) - **Per frame (hot path):** one clock read, a few integer ops (the @@ -227,7 +266,12 @@ tick is not counted and not recorded (the tick-count contract, ring write per completed tick (the `runOneTick` `TimeIt`) — no allocation; the measured enabled cost is bounded at 1% of a 10k-entity tick (the m1-profiler-cost baseline, CORE-001/DBG-004). - Profiler null or disabled: one branch per tick, nothing else. + Profiler null or disabled: one branch per tick, nothing else. The + G-R1 watch (M1-ALLOC-01, debug builds only) adds three atomic + stores per completed tick (the arm: first-site, count, armed flag) + + two atomic loads (the read) — no allocation, no logging on the + healthy path; release builds compile the check out entirely (the + section above). - **Cold path (overload):** one rate-limited `tick_dropped` warn with field construction — only while a frame exceeds the catch-up bound. - **Complexity:** `frame()` is O(maxCatchUpTicks × per-tick system diff --git a/docs/api/profiler.md b/docs/api/profiler.md index f50ed21..113efd8 100644 --- a/docs/api/profiler.md +++ b/docs/api/profiler.md @@ -265,8 +265,12 @@ compressed into one greppable line. wall-clock diagnostics (ARCH-009): never in the tick count, the state hash, or a replay (the M1-SYS-03 precedent). - **`sim_allocs` is a total, not a per-frame delta** — the steady- - state **per-frame delta** is the FR-11.1 target of 0 (M1-ALLOC-01 - asserts it per tick via the allocation hook). + state **per-frame delta** is the FR-11.1 target of 0. M1-ALLOC-01 + enforces it directly in debug builds: the per-tick allocation watch + ([api/alloc_watch.md](alloc_watch.md)) arms around each completed + tick and asserts a zero count (the diagnostic subsystem's own emit + is attributed to it, not the tick — see that doc's attribution + note). ## Testing and CI diff --git a/laige-api.json b/laige-api.json index 6c6934a..22b1414 100644 --- a/laige-api.json +++ b/laige-api.json @@ -2,6 +2,7 @@ "version": 1, "generatedBy": "laige-api", "headers": [ + "src/laige-core/include/laige/alloc_watch.h", "src/laige-core/include/laige/budget_harness.h", "src/laige-core/include/laige/core/version.h", "src/laige-core/include/laige/errors.h", @@ -28,6 +29,15 @@ "src/laige-sim/include/laige/sim/system.h" ], "symbols": [ + {"name": "laige::AllocWatchReading", "kind": "struct", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 130, "signature": "struct AllocWatchReading", "summary": "One armed-window reading (see the header preamble): the heap- allocation count since the last arm() and the call site of the first offending allocation (nullptr while none).", "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading::allocs", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 131, "signature": "std::uint64_t allocs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading::firstSite", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 132, "signature": "const void* firstSite{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 139, "signature": "void allocWatchArm() noexcept", "summary": "Start a fresh watch window: reset the window's allocation count and clear the first-site capture (see the header preamble for the window model, the cost, and the threading contract). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 143, "signature": "[[nodiscard]] AllocWatchReading allocWatchRead() noexcept", "summary": "Read the current armed window (see AllocWatchReading). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 149, "signature": "[[nodiscard]] bool allocWatchLive() noexcept", "summary": "True when the process-wide counting backend is compiled into this build (every non-sanitizer tree); false in the sanitizer trees, where the runtimes own operator new/delete and the watch is a no-op (the header's scope section).", "budget": null, "experimental": false}, + {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 177, "signature": "inline void allocWatchArm() noexcept", "summary": "The no-op fallback (the sanitizer trees): the counting backend is not compiled in, so an armed window never sees anything. The functions are inline no-ops — the engine's per-tick check and the test-side probes (tests/**/logging_alloc_counter.h) compile unchanged and read zero.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 178, "signature": "inline AllocWatchReading allocWatchRead() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 181, "signature": "inline bool allocWatchLive() noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::HistogramStats", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 155, "signature": "struct HistogramStats", "summary": "Summary statistics over the samples currently stored in a Histogram (rolling window). When n == 0 the six statistics are NaN (check n; budgetCheck turns an empty histogram into a loud NO_SAMPLES failure).", "budget": null, "experimental": false}, {"name": "laige::HistogramStats::n", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 156, "signature": "std::uint64_t n", "summary": null, "budget": null, "experimental": false}, {"name": "laige::HistogramStats::min", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 157, "signature": "double min", "summary": null, "budget": null, "experimental": false}, @@ -170,89 +180,89 @@ {"name": "laige::JsonValue::operator!=", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 232, "signature": "[[nodiscard]] bool operator!=(const JsonValue& other) const noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::parseJson", "kind": "function", "header": "src/laige-core/include/laige/json.h", "line": 255, "signature": "[[nodiscard]] Result parseJson(std::string_view input, JsonOptions options = {})", "summary": "Parses exactly one JSON document from `input` (the whole view must be consumed; trailing non-whitespace is MalformedInput). Bounded by `options` (defaults: 1 MiB, depth 32 — see the preamble). Every failure is ErrorCode::MalformedInput; a failed parse produces no value.", "budget": null, "experimental": false}, {"name": "laige::serializeJson", "kind": "function", "header": "src/laige-core/include/laige/json.h", "line": 267, "signature": "[[nodiscard]] std::string serializeJson(const JsonValue& value)", "summary": "Serializes `value` to canonical compact JSON (no insignificant whitespace): strings ASCII-safe (\\uXXXX for control characters and every codepoint above 0x7F; the two-character escapes for the six printable ones), numbers shortest-round-trip decimal (see the preamble), objects and arrays in stored order. Precondition: no Number holding NaN or +/-inf anywhere in the value (debug assert; documented undefined behavior in release). Cold path: allocates one output string plus recursive calls per nesting level.", "budget": null, "experimental": false}, - {"name": "laige::log::Severity", "kind": "enum", "header": "src/laige-core/include/laige/logging.h", "line": 93, "signature": "enum class Severity : std::uint8_t", "summary": "Event severity. Contract per level (AGENTS.md §14): Trace very high-volume diagnostic detail; disabled by default Debug developer-facing state Info low-volume lifecycle / significant state transitions Warn degraded behavior the engine recovered from Error an operation or subsystem failed Fatal continued execution is unsafe: the facade records the event, flushes, and terminates the process (std::abort) — controlled termination after preserving diagnostics", "budget": null, "experimental": false}, - {"name": "laige::log::Severity::Trace", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 94, "signature": "Trace = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Severity::Debug", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 95, "signature": "Debug = 1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Severity::Info", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 96, "signature": "Info = 2", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Severity::Warn", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 97, "signature": "Warn = 3", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Severity::Error", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 98, "signature": "Error = 4", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Severity::Fatal", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 99, "signature": "Fatal = 5", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level", "kind": "enum", "header": "src/laige-core/include/laige/logging.h", "line": 106, "signature": "enum class Level : std::uint8_t", "summary": "Minimum-severity filter, used either logger-wide (global minimum) or for one subsystem (per-subsystem scope, FR-12.2). An event is recorded only when severity >= the applicable level. Off disables everything (the cheap switch for release/server profiles).", "budget": null, "experimental": false}, - {"name": "laige::log::Level::Trace", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 107, "signature": "Trace = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level::Debug", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 108, "signature": "Debug = 1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level::Info", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 109, "signature": "Info = 2", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level::Warn", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 110, "signature": "Warn = 3", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level::Error", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 111, "signature": "Error = 4", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level::Fatal", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 112, "signature": "Fatal = 5", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Level::Off", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 113, "signature": "Off = 6", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::severityName", "kind": "function", "header": "src/laige-core/include/laige/logging.h", "line": 118, "signature": "[[nodiscard]] inline const char* severityName(Severity severity) noexcept", "summary": "The stable lowercase token for a severity, as rendered in log lines. O(1), no allocation, thread-safe.", "budget": null, "experimental": false}, - {"name": "laige::log::kRateLimitedEvent", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 132, "signature": "inline constexpr const char* kRateLimitedEvent = \"rate_limited\"", "summary": "The stable event name of the rate-limit summary (LOG-001: machine searchable). A summary reports suppressed repeats of another event.", "budget": null, "experimental": false}, - {"name": "laige::log::Field", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 145, "signature": "struct Field", "summary": "One structured key/value pair of a log event.", "budget": null, "experimental": false}, - {"name": "laige::log::Field::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 146, "signature": "std::string_view name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Field::value", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 147, "signature": "std::string value", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::field", "kind": "function", "header": "src/laige-core/include/laige/logging.h", "line": 204, "signature": "template Field field(std::string_view name, const T& value)", "summary": "Build a log field from a scalar value (see Field).", "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 231, "signature": "struct LogRecord", "summary": "One recorded log event — what a Sink receives.", "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::timestamp", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 236, "signature": "std::chrono::system_clock::time_point timestamp", "summary": "One documented clock (AGENTS §14): std::chrono::system_clock, rendered by the sinks in UTC as \"YYYY-MM-DDTHH:MM:SS.ffffffZ\" (RFC 3339). Diagnostics only — never part of authoritative state (ARCH-009).", "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 237, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 238, "signature": "std::string_view subsystem", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 239, "signature": "std::string_view event", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::message", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 240, "signature": "std::string_view message", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::fields", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 241, "signature": "std::span fields", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::LogRecord::threadId", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 244, "signature": "std::uint32_t threadId", "summary": "Emitting-thread identity (std::hash of std::thread::id; the \"thread or job identity\" field of AGENTS §14).", "budget": null, "experimental": false}, - {"name": "laige::log::Sink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 259, "signature": "class Sink", "summary": "A replaceable logging backend (FR-12.2: sink-swappable).", "budget": null, "experimental": false}, - {"name": "laige::log::Sink::~Sink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 261, "signature": "virtual ~Sink() = default", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Sink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 262, "signature": "virtual void emit(const LogRecord& record) = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Sink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 263, "signature": "virtual void flush() = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::ConsoleSink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 273, "signature": "class ConsoleSink : public Sink", "summary": "Sink writing one line per event to a std::FILE stream (default: stderr). The sink does NOT own the stream — it never fopens or fcloses it (a ConsoleSink(stderr) must outlive the process and the process must keep stderr usable for crash diagnostics).", "budget": null, "experimental": false}, - {"name": "laige::log::ConsoleSink::ConsoleSink", "kind": "constructor", "header": "src/laige-core/include/laige/logging.h", "line": 275, "signature": "explicit ConsoleSink(std::FILE* stream) : stream_(stream)", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::ConsoleSink::~ConsoleSink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 276, "signature": "~ConsoleSink() override", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::ConsoleSink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 278, "signature": "void emit(const LogRecord& record) override", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::ConsoleSink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 279, "signature": "void flush() override", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::ConsoleSink::failedWrites", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 282, "signature": "[[nodiscard]] std::uint64_t failedWrites() const noexcept", "summary": "Records whose line could not be written (0 = healthy).", "budget": null, "experimental": false}, - {"name": "laige::log::FileSink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 298, "signature": "class FileSink : public Sink", "summary": "Sink appending one line per event to a file.", "budget": null, "experimental": false}, - {"name": "laige::log::FileSink::create", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 307, "signature": "[[nodiscard]] static laige::Result> create(std::string path)", "summary": "Opens `path` in binary append mode (platform-stable on-disk format: LF-terminated lines, no Windows text-mode CRLF translation) with plain-`fopen` sharing semantics: the file may be opened read-only concurrently — even by the same process — on every platform, including Windows (where the secure `fopen_s` would deny even that). Never throws (NFR-8.10): a failed open is a Status carrying ErrorCode::IoError.", "budget": null, "experimental": false}, - {"name": "laige::log::FileSink::~FileSink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 310, "signature": "~FileSink() override", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::FileSink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 312, "signature": "void emit(const LogRecord& record) override", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::FileSink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 313, "signature": "void flush() override", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::FileSink::failedWrites", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 316, "signature": "[[nodiscard]] std::uint64_t failedWrites() const noexcept", "summary": "Records whose line could not be written (0 = healthy).", "budget": null, "experimental": false}, - {"name": "laige::log::FileSink::path", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 319, "signature": "[[nodiscard]] std::string_view path() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 344, "signature": "struct LoggerOptions", "summary": "Init-phase configuration for Logger::init().", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::sink", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 347, "signature": "std::unique_ptr sink = nullptr", "summary": "The sink to use; null → a ConsoleSink on stderr. The logger takes ownership (unique_ptr).", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::globalMinimum", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 350, "signature": "Level globalMinimum = Level::Debug", "summary": "Global minimum severity, checked before the per-subsystem level — one atomic load, the cheap first gate.", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::defaultSubsystemLevel", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 353, "signature": "Level defaultSubsystemLevel = Level::Debug", "summary": "Level applied to subsystems not registered via setSubsystemLevel(). Trace is disabled by default (AGENTS §14).", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::rateLimiting", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 356, "signature": "bool rateLimiting = true", "summary": "LOG-004: repeated failures are rate-limited per (subsystem, event, severity) for Warn/Error/Fatal.", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::rateWindow", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 361, "signature": "std::chrono::milliseconds rateWindow = std::chrono::milliseconds(1000)", "summary": "Rate window: at most one event per key per window reaches the sink; the rest are counted and reported in a `rate_limited` summary event when the next event for the key lands after the window (and at shutdown for pending counts).", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::ClockFn", "kind": "alias", "header": "src/laige-core/include/laige/logging.h", "line": 365, "signature": "using ClockFn = std::chrono::system_clock::time_point (*)()", "summary": "Clock for timestamps and rate decisions; null → std::chrono::system_clock::now(). Called only for enabled events (never on the disabled path); injectable for tests.", "budget": null, "experimental": false}, - {"name": "laige::log::LoggerOptions::clock", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 366, "signature": "ClockFn clock = nullptr", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 373, "signature": "class Logger", "summary": "The one logging facade (AGENTS §14): a process-lifetime Meyers singleton. See the header top for ownership, threading, and performance contracts; the full API contract is in docs/api/logging.md.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::instance", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 375, "signature": "[[nodiscard]] static Logger& instance()", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::init", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 385, "signature": "[[nodiscard]] laige::Status init(LoggerOptions options)", "summary": "Init-phase configuration (MUST NOT run concurrently with logging from other threads). Replaces the current sink (flushed first) and resets subsystem levels, rate state, and the retired flag; a previously installed crash handler is re-registered by a later installCrashHandling() call. Always succeeds: a sink that can fail is created via FileSink::create() before init (hand its sink over with Result::takeValue()). Takes options by value and consumes the sink ownership — call with an rvalue.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::setSubsystemLevel", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 389, "signature": "void setSubsystemLevel(std::string_view subsystem, Level level)", "summary": "Per-subsystem level filter (FR-12.2 per-subsystem scopes). Init-phase API. The subsystem name is copied into the facade.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::subsystemLevel", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 393, "signature": "[[nodiscard]] Level subsystemLevel(std::string_view subsystem) const", "summary": "The effective level for `subsystem` (its registered level, or defaultSubsystemLevel_ when unregistered). Init-phase API.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::setGlobalMinimum", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 395, "signature": "void setGlobalMinimum(Level level)", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::globalMinimum", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 396, "signature": "[[nodiscard]] Level globalMinimum() const noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::enabled", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 402, "signature": "[[nodiscard]] bool enabled(Severity severity, std::string_view subsystem) const", "summary": "Cheap gate behind LAIGE_LOG_*: true only when an event of `severity` from `subsystem` will be recorded. Cost: one atomic load, plus (only if that passes) one mutex section over a small linear scan — no allocation, no formatting (LOG-003).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 410, "signature": "template void emit(Severity severity, std::string_view subsystem, std::string_view event, std::string_view message, Fields&&... fields)", "summary": "Record an enabled event. Fields are moved into the record; the subsystem/event/message string_views must outlive the call. Direct calls evaluate their arguments eagerly — prefer the LAIGE_LOG_* macros (lazy). Fatal events flush and then terminate the process (AGENTS §14 controlled termination).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 419, "signature": "void flush()", "summary": "Flush the sink (LOG-007).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::shutdown", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 424, "signature": "void shutdown()", "summary": "Controlled shutdown (CONC-006, idempotent): drain pending rate-limit summaries, flush the sink, and retire the facade — log calls after shutdown are discarded (no sink calls).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 433, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 436, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 440, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, - {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 446, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 447, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 455, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 456, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 457, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 458, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 459, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 460, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 524, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 528, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 531, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 534, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 537, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 540, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, - {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 543, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity", "kind": "enum", "header": "src/laige-core/include/laige/logging.h", "line": 95, "signature": "enum class Severity : std::uint8_t", "summary": "Event severity. Contract per level (AGENTS.md §14): Trace very high-volume diagnostic detail; disabled by default Debug developer-facing state Info low-volume lifecycle / significant state transitions Warn degraded behavior the engine recovered from Error an operation or subsystem failed Fatal continued execution is unsafe: the facade records the event, flushes, and terminates the process (std::abort) — controlled termination after preserving diagnostics", "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Trace", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 96, "signature": "Trace = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Debug", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 97, "signature": "Debug = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Info", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 98, "signature": "Info = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Warn", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 99, "signature": "Warn = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Error", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 100, "signature": "Error = 4", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Fatal", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 101, "signature": "Fatal = 5", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level", "kind": "enum", "header": "src/laige-core/include/laige/logging.h", "line": 108, "signature": "enum class Level : std::uint8_t", "summary": "Minimum-severity filter, used either logger-wide (global minimum) or for one subsystem (per-subsystem scope, FR-12.2). An event is recorded only when severity >= the applicable level. Off disables everything (the cheap switch for release/server profiles).", "budget": null, "experimental": false}, + {"name": "laige::log::Level::Trace", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 109, "signature": "Trace = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Debug", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 110, "signature": "Debug = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Info", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 111, "signature": "Info = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Warn", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 112, "signature": "Warn = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Error", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 113, "signature": "Error = 4", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Fatal", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 114, "signature": "Fatal = 5", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Off", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 115, "signature": "Off = 6", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::severityName", "kind": "function", "header": "src/laige-core/include/laige/logging.h", "line": 120, "signature": "[[nodiscard]] inline const char* severityName(Severity severity) noexcept", "summary": "The stable lowercase token for a severity, as rendered in log lines. O(1), no allocation, thread-safe.", "budget": null, "experimental": false}, + {"name": "laige::log::kRateLimitedEvent", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 134, "signature": "inline constexpr const char* kRateLimitedEvent = \"rate_limited\"", "summary": "The stable event name of the rate-limit summary (LOG-001: machine searchable). A summary reports suppressed repeats of another event.", "budget": null, "experimental": false}, + {"name": "laige::log::Field", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 147, "signature": "struct Field", "summary": "One structured key/value pair of a log event.", "budget": null, "experimental": false}, + {"name": "laige::log::Field::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 148, "signature": "std::string_view name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Field::value", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 149, "signature": "std::string value", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::field", "kind": "function", "header": "src/laige-core/include/laige/logging.h", "line": 206, "signature": "template Field field(std::string_view name, const T& value)", "summary": "Build a log field from a scalar value (see Field).", "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 233, "signature": "struct LogRecord", "summary": "One recorded log event — what a Sink receives.", "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::timestamp", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 238, "signature": "std::chrono::system_clock::time_point timestamp", "summary": "One documented clock (AGENTS §14): std::chrono::system_clock, rendered by the sinks in UTC as \"YYYY-MM-DDTHH:MM:SS.ffffffZ\" (RFC 3339). Diagnostics only — never part of authoritative state (ARCH-009).", "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 239, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 240, "signature": "std::string_view subsystem", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 241, "signature": "std::string_view event", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::message", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 242, "signature": "std::string_view message", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::fields", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 243, "signature": "std::span fields", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::threadId", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 246, "signature": "std::uint32_t threadId", "summary": "Emitting-thread identity (std::hash of std::thread::id; the \"thread or job identity\" field of AGENTS §14).", "budget": null, "experimental": false}, + {"name": "laige::log::Sink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 261, "signature": "class Sink", "summary": "A replaceable logging backend (FR-12.2: sink-swappable).", "budget": null, "experimental": false}, + {"name": "laige::log::Sink::~Sink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 263, "signature": "virtual ~Sink() = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Sink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 264, "signature": "virtual void emit(const LogRecord& record) = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Sink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 265, "signature": "virtual void flush() = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 275, "signature": "class ConsoleSink : public Sink", "summary": "Sink writing one line per event to a std::FILE stream (default: stderr). The sink does NOT own the stream — it never fopens or fcloses it (a ConsoleSink(stderr) must outlive the process and the process must keep stderr usable for crash diagnostics).", "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::ConsoleSink", "kind": "constructor", "header": "src/laige-core/include/laige/logging.h", "line": 277, "signature": "explicit ConsoleSink(std::FILE* stream) : stream_(stream)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::~ConsoleSink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 278, "signature": "~ConsoleSink() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 280, "signature": "void emit(const LogRecord& record) override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 281, "signature": "void flush() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::failedWrites", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 284, "signature": "[[nodiscard]] std::uint64_t failedWrites() const noexcept", "summary": "Records whose line could not be written (0 = healthy).", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 300, "signature": "class FileSink : public Sink", "summary": "Sink appending one line per event to a file.", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::create", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 309, "signature": "[[nodiscard]] static laige::Result> create(std::string path)", "summary": "Opens `path` in binary append mode (platform-stable on-disk format: LF-terminated lines, no Windows text-mode CRLF translation) with plain-`fopen` sharing semantics: the file may be opened read-only concurrently — even by the same process — on every platform, including Windows (where the secure `fopen_s` would deny even that). Never throws (NFR-8.10): a failed open is a Status carrying ErrorCode::IoError.", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::~FileSink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 312, "signature": "~FileSink() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 314, "signature": "void emit(const LogRecord& record) override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 315, "signature": "void flush() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::failedWrites", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 318, "signature": "[[nodiscard]] std::uint64_t failedWrites() const noexcept", "summary": "Records whose line could not be written (0 = healthy).", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::path", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 321, "signature": "[[nodiscard]] std::string_view path() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 346, "signature": "struct LoggerOptions", "summary": "Init-phase configuration for Logger::init().", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::sink", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 349, "signature": "std::unique_ptr sink = nullptr", "summary": "The sink to use; null → a ConsoleSink on stderr. The logger takes ownership (unique_ptr).", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::globalMinimum", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 352, "signature": "Level globalMinimum = Level::Debug", "summary": "Global minimum severity, checked before the per-subsystem level — one atomic load, the cheap first gate.", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::defaultSubsystemLevel", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 355, "signature": "Level defaultSubsystemLevel = Level::Debug", "summary": "Level applied to subsystems not registered via setSubsystemLevel(). Trace is disabled by default (AGENTS §14).", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::rateLimiting", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 358, "signature": "bool rateLimiting = true", "summary": "LOG-004: repeated failures are rate-limited per (subsystem, event, severity) for Warn/Error/Fatal.", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::rateWindow", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 363, "signature": "std::chrono::milliseconds rateWindow = std::chrono::milliseconds(1000)", "summary": "Rate window: at most one event per key per window reaches the sink; the rest are counted and reported in a `rate_limited` summary event when the next event for the key lands after the window (and at shutdown for pending counts).", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::ClockFn", "kind": "alias", "header": "src/laige-core/include/laige/logging.h", "line": 367, "signature": "using ClockFn = std::chrono::system_clock::time_point (*)()", "summary": "Clock for timestamps and rate decisions; null → std::chrono::system_clock::now(). Called only for enabled events (never on the disabled path); injectable for tests.", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::clock", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 368, "signature": "ClockFn clock = nullptr", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 375, "signature": "class Logger", "summary": "The one logging facade (AGENTS §14): a process-lifetime Meyers singleton. See the header top for ownership, threading, and performance contracts; the full API contract is in docs/api/logging.md.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::instance", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 377, "signature": "[[nodiscard]] static Logger& instance()", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::init", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 387, "signature": "[[nodiscard]] laige::Status init(LoggerOptions options)", "summary": "Init-phase configuration (MUST NOT run concurrently with logging from other threads). Replaces the current sink (flushed first) and resets subsystem levels, rate state, and the retired flag; a previously installed crash handler is re-registered by a later installCrashHandling() call. Always succeeds: a sink that can fail is created via FileSink::create() before init (hand its sink over with Result::takeValue()). Takes options by value and consumes the sink ownership — call with an rvalue.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::setSubsystemLevel", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 391, "signature": "void setSubsystemLevel(std::string_view subsystem, Level level)", "summary": "Per-subsystem level filter (FR-12.2 per-subsystem scopes). Init-phase API. The subsystem name is copied into the facade.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::subsystemLevel", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 395, "signature": "[[nodiscard]] Level subsystemLevel(std::string_view subsystem) const", "summary": "The effective level for `subsystem` (its registered level, or defaultSubsystemLevel_ when unregistered). Init-phase API.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::setGlobalMinimum", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 397, "signature": "void setGlobalMinimum(Level level)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::globalMinimum", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 398, "signature": "[[nodiscard]] Level globalMinimum() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::enabled", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 404, "signature": "[[nodiscard]] bool enabled(Severity severity, std::string_view subsystem) const", "summary": "Cheap gate behind LAIGE_LOG_*: true only when an event of `severity` from `subsystem` will be recorded. Cost: one atomic load, plus (only if that passes) one mutex section over a small linear scan — no allocation, no formatting (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 412, "signature": "template void emit(Severity severity, std::string_view subsystem, std::string_view event, std::string_view message, Fields&&... fields)", "summary": "Record an enabled event. Fields are moved into the record; the subsystem/event/message string_views must outlive the call. Direct calls evaluate their arguments eagerly — prefer the LAIGE_LOG_* macros (lazy). Fatal events flush and then terminate the process (AGENTS §14 controlled termination).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 421, "signature": "void flush()", "summary": "Flush the sink (LOG-007).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::shutdown", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 426, "signature": "void shutdown()", "summary": "Controlled shutdown (CONC-006, idempotent): drain pending rate-limit summaries, flush the sink, and retire the facade — log calls after shutdown are discarded (no sink calls).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 435, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 438, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 442, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 448, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 449, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 457, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 458, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 459, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 460, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 461, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 462, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 537, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 541, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 544, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 547, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 550, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 553, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 556, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PoolStats", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 109, "signature": "struct PoolStats", "summary": "One pool's accounting snapshot (PRD §10.4, FR-11.4, G-R4). A plain value the M1 profiler aggregates; there is no registration (CORE-004).", "budget": null, "experimental": false}, {"name": "laige::PoolStats::capacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 110, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PoolStats::inUse", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 111, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, @@ -637,36 +647,36 @@ {"name": "laige::FrameBudgetReport::passed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 283, "signature": "bool passed{}", "summary": "The overall pass/flag (the `overall=` line): false when any frame, budget entry, or system failed (NO_SAMPLES / NO_ENTRY included).", "budget": null, "experimental": false}, {"name": "laige::FrameBudgetReport::report", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 285, "signature": "std::string report", "summary": "The machine-greppable report text (AGENTS §12 field format).", "budget": null, "experimental": false}, {"name": "laige::buildFrameBudgetReport", "kind": "function", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 300, "signature": "[[nodiscard]] FrameBudgetReport buildFrameBudgetReport( const FrameBudgetRecorder& recorder, const Profiler& profiler, const World& world, const BudgetTable& budgets, const FrameBudgetReportOptions& options = {})", "summary": "Build the frame graph / budget report (cold path). `recorder` is the run's per-frame records, `profiler` the always-on counters (the tick window feeds the sim_tick_avg / sim_tick_p99 checks), `world` the run's world (the per-system declared budgets and the M1-SYS-03 windows — read cold, never mutated), `budgets` the budgets.json table (the sim_tick_avg / sim_tick_p99 / sim_heap_allocs entries). The report evaluates every declared budget (the preamble's semantics) and formats the text. Every failure state is loud in the text (NO_SAMPLES / NO_ENTRY / FAIL lines) — never silent (CORE-008). (the report string).", "budget": "O(systemCount × n log n + window) cold path; allocates", "experimental": false}, - {"name": "laige::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 270, "signature": "inline constexpr std::uint32_t kMinTickRateHz = 20", "summary": "The supported tick-rate range (FR-1.1: default 60 Hz, configurable 20–120 Hz). Named constants (CORE-005): a rate outside this range is rejected at loop construction.", "budget": null, "experimental": false}, - {"name": "laige::kDefaultTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 273, "signature": "inline constexpr std::uint32_t kDefaultTickRateHz = 60", "summary": "The default tick rate (FR-1.1).", "budget": null, "experimental": false}, - {"name": "laige::kMaxTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 275, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kDefaultMaxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 285, "signature": "inline constexpr std::uint32_t kDefaultMaxCatchUpTicks = 5", "summary": "The default max catch-up ticks per frame (CORE-005): at the default 60 Hz, one catch-up frame may run at most 5 ticks (~83 ms of simulation time) before the frame's demand is dropped and logged. A healthy machine runs 1 tick per frame (frames slower than the tick rate run 2–3, still under the bound); a drop fires only when a frame exceeds (maxCatchUpTicks + 1) ticks of simulation time — a real overload, not a cadence difference. Raising it is typed configuration (an ADR if the engine default changes), not a knob.", "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 297, "signature": "struct GameLoopStats", "summary": "The since-construction accounting snapshot of one GameLoop (M1-PROF-01 feed; a plain value, the EntityStats/ SystemTimingStats precedent):", "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 298, "signature": "std::uint64_t frames{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 299, "signature": "std::uint64_t ticks{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::droppedTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 300, "signature": "std::uint64_t droppedTicks{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoopStats::droppedFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 301, "signature": "std::uint64_t droppedFrames{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop", "kind": "class", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 307, "signature": "class GameLoop", "summary": "The fixed-timestep accumulator loop (M1-LOOP-01): see the header preamble for the accumulator, configuration, overload, beginFrame, failure, determinism, performance, and threading contracts.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 312, "signature": "struct Options", "summary": "The typed loop configuration (API-006): the tick rate (20–120 Hz, validated at construction), the max catch-up ticks per frame (>= 1, validated), and the clock source.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 315, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20–120 validated; default kDefaultTickRateHz).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::maxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 318, "signature": "std::uint32_t maxCatchUpTicks{kDefaultMaxCatchUpTicks}", "summary": "The max ticks one frame may run before its due-tick demand is dropped (and logged): >= 1 (default kDefaultMaxCatchUpTicks).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::ClockFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 324, "signature": "using ClockFn = std::int64_t (*)()", "summary": "The clock source: nanoseconds since a fixed monotonic epoch (the same time base as the default clock below). nullptr uses the headless monotonic clock (steady_clock); a test or the M2 windowed clock supplies its own (injectable for tests — the LoggerOptions::ClockFn precedent).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::nowNs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 325, "signature": "ClockFn nowNs{nullptr}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::TickFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 333, "signature": "using TickFn = void (*)(void* context, World& world, std::uint64_t tick) noexcept", "summary": "Optional per-completed-tick callback (M1-LOOP-02; see the preamble \"Per-tick presentation hook\"): fires after every completed tick as onTick(context, world, tick). nullptr (default): no hook (the M1-LOOP-01 behavior). Plain function pointer — no std::function (PERF-006); the callback must be bounded and allocation-free (the snapshot's onTick is the reference contract).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::onTick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 335, "signature": "TickFn onTick{nullptr}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::onTickContext", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 338, "signature": "void* onTickContext{nullptr}", "summary": "The onTick callback's user context (opaque; must outlive the loop — the engine passes the PresentationSnapshot, M1-HEAD-01).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::Options::profiler", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 350, "signature": "Profiler* profiler{nullptr}", "summary": "The per-completed-tick profiler (M1-PROF-01): when non-null and enabled, runOneTick times each tick (the M0-CORE-08 TimeIt — two steady_clock reads) and hands the measured ms to Profiler::recordTick; a failed tick is not recorded (the tick counts only when the system phase completes — the preamble \"Failure behavior\"). nullptr (the default): no tick timing — one branch per tick, nothing else (DBG-004; the measured enabled cost is bounded at 1% of a 10k-entity tick — docs/benchmarks/baselines/m1-profiler-cost.md). NON-OWNING: the profiler must outlive the loop (the onTickContext lifetime contract).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 362, "signature": "[[nodiscard]] static Result create(World& world, const SystemSchedule& schedule, Options options) noexcept", "summary": "Construct the loop on `world` running `schedule` (setup phase, after World::scheduleSystems — the schedule must describe the world's CURRENT registry, and both must outlive the loop). O(1); no allocation (the loop state is fixed scalars).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::frame", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 380, "signature": "[[nodiscard]] Status frame() noexcept", "summary": "Advance one presentation frame (the hot path; see the preamble \"Performance\"): read the clock, run the frame's due ticks (up to maxCatchUpTicks), drop the excess with a rate-limited warn.", "budget": "O(maxCatchUpTicks × per-tick system work); bounded, no allocation.", "experimental": false}, - {"name": "laige::GameLoop::currentTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 384, "signature": "[[nodiscard]] std::uint64_t currentTick() const noexcept", "summary": "The number of completed ticks (0 before the first; the first tick to complete is tick 1). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::startReferenceNs", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 393, "signature": "[[nodiscard]] std::int64_t startReferenceNs() const noexcept", "summary": "The clock reading that established the start reference (0 before the first frame) — the time-base origin of the due computation (the preamble \"The exact due computation\"). The M1-LOOP-02 PresentationSnapshot takes this as its start reference (presentation.h: the tick anchors A(T) = startNs + T × 10⁹ / rate must use the loop's own time base). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::tickRateHz", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 396, "signature": "[[nodiscard]] std::uint32_t tickRateHz() const noexcept", "summary": "The configured tick rate (Hz). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::maxCatchUpTicks", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 400, "signature": "[[nodiscard]] std::uint32_t maxCatchUpTicks() const noexcept", "summary": "The configured max catch-up ticks per frame. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 405, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The since-construction accounting snapshot (GameLoopStats). O(1), no allocation, no side effects (a pure query, the World::stats() precedent).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 411, "signature": "GameLoop(GameLoop&& other) noexcept", "summary": "Move transfers the tick state; the source becomes a valid but STOPPED loop (frame() returns InvalidArgument, no log — see the preamble \"Failure behavior\"; the World moved-from precedent: the source is left in a well-defined state).", "budget": null, "experimental": false}, - {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 412, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 413, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 414, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 316, "signature": "inline constexpr std::uint32_t kMinTickRateHz = 20", "summary": "The supported tick-rate range (FR-1.1: default 60 Hz, configurable 20–120 Hz). Named constants (CORE-005): a rate outside this range is rejected at loop construction.", "budget": null, "experimental": false}, + {"name": "laige::kDefaultTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 319, "signature": "inline constexpr std::uint32_t kDefaultTickRateHz = 60", "summary": "The default tick rate (FR-1.1).", "budget": null, "experimental": false}, + {"name": "laige::kMaxTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 321, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultMaxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 331, "signature": "inline constexpr std::uint32_t kDefaultMaxCatchUpTicks = 5", "summary": "The default max catch-up ticks per frame (CORE-005): at the default 60 Hz, one catch-up frame may run at most 5 ticks (~83 ms of simulation time) before the frame's demand is dropped and logged. A healthy machine runs 1 tick per frame (frames slower than the tick rate run 2–3, still under the bound); a drop fires only when a frame exceeds (maxCatchUpTicks + 1) ticks of simulation time — a real overload, not a cadence difference. Raising it is typed configuration (an ADR if the engine default changes), not a knob.", "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 343, "signature": "struct GameLoopStats", "summary": "The since-construction accounting snapshot of one GameLoop (M1-PROF-01 feed; a plain value, the EntityStats/ SystemTimingStats precedent):", "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 344, "signature": "std::uint64_t frames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 345, "signature": "std::uint64_t ticks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::droppedTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 346, "signature": "std::uint64_t droppedTicks{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoopStats::droppedFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 347, "signature": "std::uint64_t droppedFrames{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop", "kind": "class", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 353, "signature": "class GameLoop", "summary": "The fixed-timestep accumulator loop (M1-LOOP-01): see the header preamble for the accumulator, configuration, overload, beginFrame, failure, determinism, performance, and threading contracts.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 358, "signature": "struct Options", "summary": "The typed loop configuration (API-006): the tick rate (20–120 Hz, validated at construction), the max catch-up ticks per frame (>= 1, validated), and the clock source.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 361, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20–120 validated; default kDefaultTickRateHz).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::maxCatchUpTicks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 364, "signature": "std::uint32_t maxCatchUpTicks{kDefaultMaxCatchUpTicks}", "summary": "The max ticks one frame may run before its due-tick demand is dropped (and logged): >= 1 (default kDefaultMaxCatchUpTicks).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::ClockFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 370, "signature": "using ClockFn = std::int64_t (*)()", "summary": "The clock source: nanoseconds since a fixed monotonic epoch (the same time base as the default clock below). nullptr uses the headless monotonic clock (steady_clock); a test or the M2 windowed clock supplies its own (injectable for tests — the LoggerOptions::ClockFn precedent).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::nowNs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 371, "signature": "ClockFn nowNs{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::TickFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 379, "signature": "using TickFn = void (*)(void* context, World& world, std::uint64_t tick) noexcept", "summary": "Optional per-completed-tick callback (M1-LOOP-02; see the preamble \"Per-tick presentation hook\"): fires after every completed tick as onTick(context, world, tick). nullptr (default): no hook (the M1-LOOP-01 behavior). Plain function pointer — no std::function (PERF-006); the callback must be bounded and allocation-free (the snapshot's onTick is the reference contract).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::onTick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 381, "signature": "TickFn onTick{nullptr}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::onTickContext", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 384, "signature": "void* onTickContext{nullptr}", "summary": "The onTick callback's user context (opaque; must outlive the loop — the engine passes the PresentationSnapshot, M1-HEAD-01).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::Options::profiler", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 396, "signature": "Profiler* profiler{nullptr}", "summary": "The per-completed-tick profiler (M1-PROF-01): when non-null and enabled, runOneTick times each tick (the M0-CORE-08 TimeIt — two steady_clock reads) and hands the measured ms to Profiler::recordTick; a failed tick is not recorded (the tick counts only when the system phase completes — the preamble \"Failure behavior\"). nullptr (the default): no tick timing — one branch per tick, nothing else (DBG-004; the measured enabled cost is bounded at 1% of a 10k-entity tick — docs/benchmarks/baselines/m1-profiler-cost.md). NON-OWNING: the profiler must outlive the loop (the onTickContext lifetime contract).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 408, "signature": "[[nodiscard]] static Result create(World& world, const SystemSchedule& schedule, Options options) noexcept", "summary": "Construct the loop on `world` running `schedule` (setup phase, after World::scheduleSystems — the schedule must describe the world's CURRENT registry, and both must outlive the loop). O(1); no allocation (the loop state is fixed scalars).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::frame", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 426, "signature": "[[nodiscard]] Status frame() noexcept", "summary": "Advance one presentation frame (the hot path; see the preamble \"Performance\"): read the clock, run the frame's due ticks (up to maxCatchUpTicks), drop the excess with a rate-limited warn.", "budget": "O(maxCatchUpTicks × per-tick system work); bounded, no allocation.", "experimental": false}, + {"name": "laige::GameLoop::currentTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 430, "signature": "[[nodiscard]] std::uint64_t currentTick() const noexcept", "summary": "The number of completed ticks (0 before the first; the first tick to complete is tick 1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::startReferenceNs", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 439, "signature": "[[nodiscard]] std::int64_t startReferenceNs() const noexcept", "summary": "The clock reading that established the start reference (0 before the first frame) — the time-base origin of the due computation (the preamble \"The exact due computation\"). The M1-LOOP-02 PresentationSnapshot takes this as its start reference (presentation.h: the tick anchors A(T) = startNs + T × 10⁹ / rate must use the loop's own time base). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::tickRateHz", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 442, "signature": "[[nodiscard]] std::uint32_t tickRateHz() const noexcept", "summary": "The configured tick rate (Hz). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::maxCatchUpTicks", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 446, "signature": "[[nodiscard]] std::uint32_t maxCatchUpTicks() const noexcept", "summary": "The configured max catch-up ticks per frame. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 451, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The since-construction accounting snapshot (GameLoopStats). O(1), no allocation, no side effects (a pure query, the World::stats() precedent).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 457, "signature": "GameLoop(GameLoop&& other) noexcept", "summary": "Move transfers the tick state; the source becomes a valid but STOPPED loop (frame() returns InvalidArgument, no log — see the preamble \"Failure behavior\"; the World moved-from precedent: the source is left in a well-defined state).", "budget": null, "experimental": false}, + {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 458, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 459, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 460, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Position2D", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 258, "signature": "template struct Position2D", "summary": "The entity's 2D simulation-space position (the ground plane — PRD §4; the axes/units contract lands with the concepts docs). The value is the selected SimMath backend's Vec2 (ADR 0002: one template instantiation per backend, factory-selected at engine init). A data carrier (S-8): trivially copyable, no behavior — the LAIGE_COMPONENT marks below register both instantiations in the same path as user components (M1-ECS-02).", "budget": null, "experimental": false}, {"name": "laige::Position2D::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 260, "signature": "sim::SimMath::Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 263, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 9a17c47..662ea40 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -248,7 +248,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R budget_report` green; sample report file committed as fixture. - **Size:** ~200 lines + tests -- [ ] **M1-ALLOC-01 · Zero sim-loop allocation assertion (G-R1)** +- [x] **M1-ALLOC-01 · Zero sim-loop allocation assertion (G-R1)** - **Refs:** PRD §9.3 G-R1, §8.1 (0 per frame in sim); PERF-003 - **Depends:** M1-PROF-01, M1-ECS-03, M1-ECS-07, M1-LOOP-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 87d266e..9696d7e 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 22 | 🚧 in progress (M1-ALLOC-01) | +| M1 | 25 | 23 | 🚧 in progress (M1-BENCH-01) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **40** | | +| **Total** | **193** | **41** | | --- @@ -216,6 +216,7 @@ One line per completed (or split/renumbered) step. | 2026-09-21 | M1-CFG-01 | `—` | Declarative game config (FR-1.5, ARCH-007, ADR 0002/0003, M1-CFG-01 scope, nothing else): the version 1 `config.json` schema in a new public header `src/laige-sim/include/laige/sim/config.h` (+ `config.cpp`) — `EngineConfig` MOVED from engine.h (the five original members keep their order, so existing aggregate initializers compile unchanged) gains the `budgets` block (system_time_default_ms, draw_calls_per_frame, particles_per_frame — declared values before their M2 consumers), the `camera` block (fov_degrees, zoom, follow_lerp_per_sec — stored, consumed M2), and `asset_roots` (non-empty strings; no existence check — the M2 asset pipeline owns it); the REQUIRED `version` key gates before every other key (missing → `config/version_missing`, non-integer → `config/version_invalid`, ≠ 1 → `config/version_unsupported` — the provisional M1-HEAD-01 documents migrate by adding `"version": 1`); unknown keys at any level warn `config/unknown_key` and are ignored (forward-compat); first failure wins in document order; every rejection is a rate-limited warn with the NFR-13.3 5-field text (one named constant per rejection, LOG-002) + `InvalidArgument`; `loadGameConfig(path)` (bounded 1 MiB read + the M0-CORE-07 parse + the schema) replaces the CLI's read/parse sequence (exit codes unchanged); `EngineConfigOverride` + `applyConfigOverride` (the FR-1.5 programmatic override-of-a-subset merge: per-leaf optionals, each set field validated in its documented domain, first set field that fails wins, value semantics, `assetRoots` replacement); `ConfigHotReloader` (move-only, no thread, caller-driven poll — debug builds ONLY, the replay/`record_disabled` pattern: release builds reject with `config/hot_reload_disabled`): baseline bytes + validated baseline, byte compare per poll, non-sim changes (camera.*, draw/particle budgets, asset_roots) apply in place + `config/hot_reload_applied` (Info, keys named) + baseline advance, sim-affecting changes (version, tick_rate_hz, entity_budget, churn_per_frame_budget, seed, determinism.*, budgets.system_time_default_ms) refused ATOMICALLY + `config/hot_reload_rejected` (Error, first key + old/new) with config/baseline untouched, read/parse/schema failures keep the previous config (`config/hot_reload_read_failed` or the loader's event), moved-from poll fails without logging (stopped-state precedent); the replay identity's `configHash` covers only the sim-affecting fields — the declared presentation values are deliberately excluded, so the encoding (tag 1) is UNCHANGED and every committed baseline/replay log stays valid (replay.h/.cpp comments updated); docs: NEW `docs/api/config.md` (key table, versioning + migration, rejection table, the override API, the hot-reload contract, Performance, misuse), engine.md config section rewritten to the final surface, replay.md/determinism.md/testing.md/docs-README/sim-README cross-refs updated; the provisional config surface in engine.h/engine.cpp folded into config.h/config.cpp (engine.h includes config.h; `Engine::create` re-validates the tick rate with the shared `kConfigTickRateInvalidMessage`); fixtures gain `"version": 1` (headless_smoke.json, samples/hello/config.json, the three replay smoke fixtures); NEW `game_config` CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path — `ConfigHotReload.*`); engine_tests drops the migrated `EngineConfigParse.*` (the suites live in game_config_tests.cpp); determinism_tests' config docs gain `"version": 1`; `laige-api.json` regenerated (734 symbols); local Verify: `ctest -R config` green (config_json + game_config + hello_config_valid), full `ctest` 88/88 on `build` (Debug g++), `build-asan` (leak-free), `build-release` (NDEBUG — the `hot_reload_disabled` path exercised), `build-clang`, `build-tsan` (config suites `halt_on_error=1`), `build-shared`; zero new warnings under NFR-8.10; untested: the MSVC `_fsopen` branch (CI-only, the M1-DET-02 precedent). Size: larger than the ~300-line guidance (the message table + hot reloader + 38-test suite) — noted here per the roadmap's split rule, not split | | 2026-09-23 | M1-PROF-02 | `0c7cf5c` / PR #49 | Frame graph / budget report (FR-11.2, PRD §9.1 S-6, §9.3 G-R5; M1-PROF-02 scope, nothing else): the `FrameBudgetRecorder` fixed 32-frame ring (`src/laige-sim/include/laige/sim/frame_budget.h` + `frame_budget.cpp` — `FrameBudgetRecord` per completed frame: the 0-based frame index, the tick delta, the frame's sim work ms, the pool-reservations sim-alloc delta, the G-R5 `budget_overrun`/`budget_critical` event deltas; `recordFrame` O(1) allocation-free hot path, `at(i)` oldest-first over the wrapping ring, `totalFrames()` keeps counting past the window — no silent truncation, CORE-008) and `buildFrameBudgetReport` (cold format pass, the AGENTS §12 field format): every DECLARED budget measured vs declared with a pass/flag — each system's declared `SystemDef::budgetMs` (fpx16_16, exact — ADR 0002) vs its M1-SYS-03 rolling window's **p99** (the G-R5 sustained-overrun signal; single recovered overruns stay visible in the per-frame records + the `warns`/`errors` counters), `sim_tick_avg`/`sim_tick_p99` (budgets.json, M0-CORE-08) vs the `Profiler`'s tick window (mean/p99), `sim_heap_allocs` (the PRD §8.1 hard-zero budget) vs the per-frame sim-alloc deltas (max); the M0-CORE-08 `budgetCheck` blocks embedded verbatim; a missing entry is a loud `NO_ENTRY`, an empty window a loud `NO_SAMPLES` (a zero-tick run is never silent), the over-budget systems list ascending id, and `overall=PASS|FAIL` (a broken harness folds to FAIL — CORE-008); the ENGINE wiring (always-on per-frame accumulation — two O(1) reads + two O(systemCount) G-R5 counter passes + one O(1) ring write per frame, no allocation, no logging, PERF-003/LOG-003; the run-setup allocation count stays exactly three — `HeadlessFramePathAllocatesNothing` still pins it — the ring is created in `Engine::create`): `startBudgetReport(budgetsPath, lastNFrames)` (EVERY build — diagnostics, not replay state; loads the table now, builds + CACHES the report at the run's end on EVERY path — readable after the shutdown, the `profileStats()` precedent; a budget FAIL never fails the run — CORE-002; errors: stopped/empty-path/double-start `InvalidArgument`, load `IoError`/`MalformedInput` + `budget/report_*` structured events), `budgetReportRequested()`, `lastBudgetReport()`; the CLI surface (`tools/run/laige-run.cpp`): `--budget-report [N]` (1..32; omitted = all retained; printed to stdout after the profile summary line, even on a failed run), `--budgets ` (flag → `LAIGE_BUDGETS_PATH` env → `budgets.json` CWD — the laige-bench resolution order), `--fail-on-budget` (**exit 3** when the run COMPLETED but `overall=FAIL` — the PRD §8.1 CI gate; run failure stays exit 1, start/load failure exit 2); 14-test `budget_report` CTest entry (`tests/laige-sim/budget_report_tests.cpp`: the ring's newest-frames-oldest-first semantics, the SYNTHETIC OVER-BUDGET SYSTEM with correct numbers — the 2 ms burn vs a 0.1 ms fpx16_16 budget (20× — both G-R5 multipliers exceeded on the nominal floor, so the exact `runs=10 warns=10 errors=10` and p99 ≥ 2 assertions are preemption-tolerant): the declared budget echoed, the `over_budget:` line, `overall=FAIL`, the per-frame FAIL invariants — the engine's cached-after-shutdown report, the G-R5 events folded into the per-frame records (rate-limiting-off capture sink: 10 warns + 10 criticals exact), the healthy-world PASS, the loud NO_ENTRY/NO_SAMPLES, the start validation (double/empty/stopped/unreadable/malformed), and the record path's zero-allocation (`budget-report-recorder-zeroalloc frames=1000 allocs=0`, non-sanitizer trees)); `laige_run_budget` CLI smoke (every P0 OS job: 30-tick run, `--budget-report 4 --budgets /budgets.json --fail-on-budget`, passes iff `overall=PASS` + exit 0); sample report committed as `tests/laige-sim/fixtures/budget_report_sample.txt` (a sample, NOT a golden — the report carries wall-clock values); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostics — never enter sim state, hashes, or replays, ARCH-009; `tools/laige-determinism-lint` green); `laige-api.json` regenerated (24 headers; +`FrameBudgetRecord`/`kFrameBudgetWindow`/`FrameBudgetRecorder`/`FrameBudgetReportOptions`/`FrameBudgetReport`/`buildFrameBudgetReport` + the three `Engine` methods + `Profiler::tickWindow`); docs in the same change: `docs/api/frame_budget.md` (new — the declared budgets, the report format + pass/flag semantics, the Engine surface, the CLI + exit 3, Performance, determinism, Testing/CI) + engine.md (CLI flags, exit 3, per-frame cost, misuse, Testing) + profiler.md cross-ref + the docs/README + sim README indexes; local Verify: canonical g++ Debug tree zero-warning, `ctest -R budget_report` green (14/14), `ctest -R laige_run_budget` green, `ctest -R profiler`/`engine`/`system_timing` green (the zero-allocation pin unchanged), both lints OK | | 2026-09-21 | M1-PROF-01 | `a811297` / PR #47 | Always-on profiler counters (FR-11.1, DBG-008; M1-PROF-01 scope, nothing else): the `Profiler` core (`src/laige-sim/include/laige/sim/profiler.h` + `profiler.cpp`) — always-on, fixed-storage, no allocation after init: tick/frame time rolling windows (512/256 samples, M0-CORE-08 `Histogram`; record is O(1) allocation-free, drops the OLDEST, the since-construction counters keep counting), draw calls / texture binds / net bytes counters (0 in headless M1 — the fields exist per FR-11.1, the render/network subsystems feed them in M2/M3), and the cold `snapshot()` (own counters; `snapshot(world)` adds the world-pulled fields — entity total/alive/capacity, sim alloc count = `World::archetypeStats().totalReservations` (the M1-ECS-03 pool accounting, target 0), system count — read COLD, never copied; per-system windows stay in `World`, pulled only in the report); move-only (moved-from = stopped: records no-op, empty snapshot); `setEnabled`/`enabled` (disabled = one branch, no recording); report surface: `formatProfileSummaryLine` (the CLI one-liner), `formatProfileText` / `formatProfileJson` (version-1 schema: counters, tick/frame windows (n==0 → `n=0` text / JSON `null`, never NaN), world fields, per-system entries with the M1-SYS-03 window stats), `writeProfile` (truncating write, no partial file on failure, `Result` — `IoError`); wiring: `GameLoop::Options::profiler` (non-owning) — `runOneTick` times the tick body (the frame's `beginFrame` + one `runSystems` dispatch) with the M0-CORE-08 `TimeIt` and records on SUCCESS only (a failed tick is neither counted nor recorded), null/disabled = one branch; the engine owns the profiler (`Engine::create` constructs it — engine setup, not run setup, so the "exactly three one-shot allocations per run" claim stays true; the run loop adds the frame feed — two clock reads + one ring write per frame, excluding the pacing sleep, first frame not recorded, failed frames not recorded; `shutdown()` releases it in the pools step, abandoning a started-but-unfinalized report with `profiler/report_aborted`); the per-run report: `startProfileReport(path)` (EVERY build — diagnostics, not replay state, no `NDEBUG` gate), written at the END of the run on EVERY path (a zero-tick run writes a zero-tick report, CORE-008), a write failure does NOT fail the run (sticky `profileReportStatus()`, `profiler/report_write_failed` Error), `profileStats()` = the last run's cached snapshot (the world is released in shutdown); `laige-run --prof-out ` (start failure exits 2; a write failure leaves the run `status=ok` and exits 2) + the always-printed `laige-run profile: …` one-line summary (the byte-stable `status=ok` line untouched — the summary is a separate stdout line); structured events (subsystem `profiler`, NFR-13.3 5-field grammar): `report_started` (Info), `report_written` (Info), `report_write_failed` (Error), `report_aborted` / `report_already_started` / `report_path_invalid` (Warn); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostic — never enters sim state, hashes, or replays, ARCH-009); 26-test `profiler` CTest entry (`tests/laige-sim/profiler_tests.cpp`: exact percentiles 1..100 → p50=50/p95=95/p99=99/mean=50.5, rollover cap, independent windows, zero-capacity drop, adders, disabled no-op + preserved state, moved-from stop, cold snapshot, per-completed-tick timing (failed tick unrecorded), the engine's per-run cache + report (written on every run path, version-1 JSON parseable, double-start/empty-path/stopped-engine rejections, write failure sticky without failing the run, pre-run shutdown abandonment with no file on disk), the greppable text form, the record path's zero-allocation (`profiler-zeroalloc ticks=1000 allocs=0`, non-sanitizer trees), and the enabled-cost gate ON vs OFF over 10k-entity ticks ≤ 1% (`profiler-cost on_p50=0.557288 off_p50=0.555746 overhead_pct=0.277465`, best-of-2 per arm, non-sanitizer trees — the LAIGE_ALLOC_COUNTER gate: sanitizer instrumentation inflates the fixed per-tick cost, 1.46% on the ASan tree)); docs in the same change: `docs/api/profiler.md` (new) + engine.md / game_loop.md updates + the docs/README, sim README, debugging README, tools README indexes; baseline `docs/benchmarks/baselines/m1-profiler-cost.md` (full AGENTS §12 metadata, verbatim runs — measured +0.28% vs the 1% gate); local Verify: canonical g++ tree zero-warning, full `ctest` 89/89 (the new `profiler` entry + `api-real-tree` + `determinism-lint-real-tree` green after the manifest regeneration), `laige-run --prof-out` smoke (the summary line + the valid JSON report on disk); cross-tree builds + `tools/laige-include-lint` in the PR branch | +| 2026-09-24 | M1-ALLOC-01 | `feat/m1-alloc-01-zero-alloc-assert` | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `__return_address` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK | --- diff --git a/src/laige-core/CMakeLists.txt b/src/laige-core/CMakeLists.txt index 5babb33..f72c47f 100644 --- a/src/laige-core/CMakeLists.txt +++ b/src/laige-core/CMakeLists.txt @@ -13,14 +13,36 @@ # functional code lands in the remaining M0-CORE-xx steps. The link # smoke test in tests/laige-core/ verifies the library in both variants. +# M1-ALLOC-01: the G-R1 zero-allocation watch (the counting backend for +# the debug per-tick sim assertion, docs/api/alloc_watch.md). +# alloc_watch.cpp carries the strong global operator new/new[] +# overrides that count heap allocations into the armed window; the +# LAIGE_ALLOC_WATCH definition marks every tree where the counting +# backend is compiled in (it is PUBLIC so every consumer — the sim +# module, the tools, the test executables — sees the same state). The +# TU is excluded from the sanitizer trees: the sanitizer runtimes +# define their own new/delete, so the overrides cannot be linked +# there. There, the zero-allocation property is covered by the +# leak-free sanitizer run of the same loop plus the pool +# reservation-delta assertion (the established fallback pattern — +# tests/laige-core, M0-CORE-02/05; tests/laige-sim, M1-ECS-03/07). +if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) + set(LAIGE_CORE_SOURCES version.cpp errors.cpp logging.cpp + sim_math.cpp sim_math_fixed.cpp json.cpp budget_harness.cpp + alloc_watch.cpp) +else() + set(LAIGE_CORE_SOURCES version.cpp errors.cpp logging.cpp + sim_math.cpp sim_math_fixed.cpp json.cpp budget_harness.cpp) +endif() + if(LAIGE_BUILD_SHARED) - add_library(laige-core SHARED version.cpp errors.cpp logging.cpp - sim_math.cpp sim_math_fixed.cpp json.cpp - budget_harness.cpp) + add_library(laige-core SHARED ${LAIGE_CORE_SOURCES}) else() - add_library(laige-core STATIC version.cpp errors.cpp logging.cpp - sim_math.cpp sim_math_fixed.cpp json.cpp - budget_harness.cpp) + add_library(laige-core STATIC ${LAIGE_CORE_SOURCES}) +endif() + +if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) + target_compile_definitions(laige-core PUBLIC LAIGE_ALLOC_WATCH=1) endif() laige_apply_engine_policy(laige-core) diff --git a/src/laige-core/alloc_watch.cpp b/src/laige-core/alloc_watch.cpp new file mode 100644 index 0000000..6bcdfd7 --- /dev/null +++ b/src/laige-core/alloc_watch.cpp @@ -0,0 +1,172 @@ +// laige-core allocation watch counting backend (M1-ALLOC-01). +// +// Implementation of the allocWatchArm / allocWatchRead / allocWatchLive +// declared in include/laige/alloc_watch.h — see that header for the +// full contract (the armed-window model, the per-tick assertion it +// feeds, the scope of the counting backend, the cost, and the +// threading rules) and docs/api/alloc_watch.md for the API document. +// +// The strong definitions of the global operator new/new[] below are +// linked ahead of the CRT's weak defaults (GCC/Clang: the library +// definitions are weak; MSVC: the linker only pulls in a CRT +// allocator module to resolve undefined symbols, which this object +// already defines), so every heap allocation made anywhere in the +// process (static build trees) — or inside the engine images (shared +// build trees; the platform interposition scope in the header) — +// passes through watchRecord(). +// +// This translation unit is compiled ONLY in the non-sanitizer trees +// (the CMake gate sets LAIGE_ALLOC_WATCH on the target in exactly +// those trees): the sanitizer runtimes define their own new/delete, +// so the overrides cannot be linked there (the header's scope +// section). Test binaries that need the same counter use the +// laige::test compatibility facade (tests/**/ +// logging_alloc_counter.h), which wraps this backend — one strong +// definition per binary (the M0-CORE-02 test-TU precedent, moved +// here in M1-ALLOC-01). + +#include "laige/alloc_watch.h" // the contract (this header) + +#include +#include +#include +#include +#include + +namespace laige { + +namespace detail { + +// The watch's process state (see the header): exactly one armed +// window at a time, owned by the sim owner thread. Relaxed atomics: +// the only writes from the owner are the arm's two stores; a +// concurrent allocation from another thread reads the armed flag and +// increments the counters — observation data, not simulation state +// (CONC-001, ARCH-009). +inline std::atomic kWatchArmed{false}; +inline std::atomic kWatchAllocs{0}; +inline std::atomic kWatchFirstSite{nullptr}; + +// The logging-facade emit depth (the attribution contract, header): +// nonzero while the diagnostic subsystem is emitting an event — its +// heap work is not the sim loop's (G-R1 / sim_heap_allocs measures +// the sim loop's storage and systems, not the diagnostic subsystem's +// event memory). A counter, not a flag: nested emits (a sink that +// logs) keep the guard active until the outermost emit finishes. +inline std::atomic kLoggingEmitDepth{0}; + +} // namespace detail + +namespace detail { + +LoggingAllocationGuard::LoggingAllocationGuard() noexcept { + kLoggingEmitDepth.fetch_add(1, std::memory_order_relaxed); +} + +LoggingAllocationGuard::~LoggingAllocationGuard() noexcept { + kLoggingEmitDepth.fetch_sub(1, std::memory_order_relaxed); +} + +} // namespace detail + +void allocWatchArm() noexcept { + // Data before the flag: the owner's two stores land first, so a + // reader that sees armed==true always sees the reset values (the + // relaxed order is enough for the single-owner-thread model). + detail::kWatchFirstSite.store(nullptr, std::memory_order_relaxed); + detail::kWatchAllocs.store(0, std::memory_order_relaxed); + detail::kWatchArmed.store(true, std::memory_order_relaxed); +} + +AllocWatchReading allocWatchRead() noexcept { + const std::uint64_t allocs = + detail::kWatchAllocs.load(std::memory_order_relaxed); + const void* site = detail::kWatchFirstSite.load(std::memory_order_relaxed); + return AllocWatchReading{allocs, site}; +} + +bool allocWatchLive() noexcept { return true; } + +} // namespace laige + +namespace { + +// The allocating call's own return address — the actionable offending +// call site (FR-12.3). Evaluated DIRECTLY in the operator new frame +// (a helper function would add a frame and shift the return address +// into the helper, not the allocating caller). GCC/Clang: the +// builtin; MSVC: the __return_address macro; any other compiler: no +// site (the count still works — the assert's message then points at +// the log event only). +#if defined(__GNUC__) || defined(__clang__) +# define LAIGE_ALLOC_CALLER_SITE() __builtin_return_address(0) +#elif defined(_MSC_VER) +# define LAIGE_ALLOC_CALLER_SITE() __return_address +#else +# define LAIGE_ALLOC_CALLER_SITE() static_cast(nullptr) +#endif + +// The counting body of the operator new overrides (see the header's +// cost contract): armed → count the allocation and capture the first +// site; unarmed → exactly one atomic load + one branch. The +// attribution contract (header): while the logging facade is +// emitting an event (kLoggingEmitDepth > 0) the diagnostic +// subsystem's heap work is not the sim loop's — it is not counted. +// `site` is the allocating call's own address (the +// LAIGE_ALLOC_CALLER_SITE builtin evaluated in the operator new +// frame — one level above here). +inline void watchRecord(const void* site) noexcept { + if (laige::detail::kLoggingEmitDepth.load(std::memory_order_relaxed) > + 0) { + return; + } + if (!laige::detail::kWatchArmed.load(std::memory_order_relaxed)) { + return; + } + laige::detail::kWatchAllocs.fetch_add(1, std::memory_order_relaxed); + // The first site wins: the relaxed CAS fails once the first + // offender is recorded, so later offenders cost the failed CAS + // only (windows are short and allocation-free by contract). + const void* expected = nullptr; + (void)laige::detail::kWatchFirstSite.compare_exchange_strong( + expected, site, std::memory_order_relaxed, + std::memory_order_relaxed); +} + +} // namespace + +void* operator new(std::size_t size) { + watchRecord(LAIGE_ALLOC_CALLER_SITE()); + void* p = std::malloc(size); + if (p == nullptr) std::terminate(); // no exceptions (NFR-8.10) + return p; +} + +void* operator new[](std::size_t size) { + watchRecord(LAIGE_ALLOC_CALLER_SITE()); + void* p = std::malloc(size); + if (p == nullptr) std::terminate(); + return p; +} + +void* operator new(std::size_t size, const std::nothrow_t&) noexcept { + void* p = std::malloc(size); + if (p != nullptr) watchRecord(LAIGE_ALLOC_CALLER_SITE()); + return p; // a failed nothrow alloc is not counted +} + +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { + void* p = std::malloc(size); + if (p != nullptr) watchRecord(LAIGE_ALLOC_CALLER_SITE()); + return p; +} + +void operator delete(void* p) noexcept { std::free(p); } +void operator delete[](void* p) noexcept { std::free(p); } + +// The sized deallocations too: libstdc++ deallocates through +// operator delete(p, size), and routing them through std::free keeps +// every allocation/deallocation pair malloc/free-consistent (the +// M1-ECS-03 test-TU precedent). +void operator delete(void* p, std::size_t) noexcept { std::free(p); } +void operator delete[](void* p, std::size_t) noexcept { std::free(p); } diff --git a/src/laige-core/include/laige/alloc_watch.h b/src/laige-core/include/laige/alloc_watch.h new file mode 100644 index 0000000..e21029b --- /dev/null +++ b/src/laige-core/include/laige/alloc_watch.h @@ -0,0 +1,188 @@ +// laige-core allocation watch (M1-ALLOC-01; PRD §8.1, G-R1). +// +// G-R1 (PRD §9.3, "Zero sim-loop allocations"): the simulation's +// steady-state tick path MUST allocate nothing. The budgets.json +// `sim_heap_allocs` entry encodes the budget (target 0 allocs per +// frame, "asserted in debug builds"); this header is the debug-side +// enforcement mechanism the guardrail table names: "Debug: +// allocation counter + assert". The release side of the guardrail +// (pool overflow → logged degradation, no crash) already lives in the +// pool accounting (pools.h, M0-CORE-05) and the per-frame simAllocs +// delta (M1-PROF-01/02 frame report) — nothing new ships there. +// +// The watch is a process-wide heap-allocation counter with an ARMED +// WINDOW: +// +// - allocWatchArm() starts a fresh window: it resets the window's +// allocation count and clears the first-site +// capture. One armed window at a time; the +// window is live until the next arm(). +// - allocWatchRead() reads the window: the allocation count since +// arm() plus the call site of the FIRST +// offending allocation (nullptr while none). +// +// The counting backend is a strong definition of the global operator +// new/new[] (alloc_watch.cpp, compiled in every non-sanitizer build +// tree): while a window is armed, every heap allocation made by ANY +// translation unit — engine storage, the pools, a system's local +// std::vector, even a hot-path log — increments the window count, and +// the caller's return address of the first offending allocation is +// captured (the actionable call site, FR-12.3). While no window is +// armed, each allocation pays exactly one atomic load + one branch. +// +// --------------------------------------------------------------------------- +// The per-tick assertion (the laige-sim half of M1-ALLOC-01) +// --------------------------------------------------------------------------- +// +// In DEBUG builds, GameLoop::runOneTick() (laige/sim/game_loop.h, +// "The zero-allocation check") arms a fresh window before the tick +// body and reads it after a COMPLETED tick: a tick with a nonzero +// window count fails with one structured Error event +// (alloc/sim_tick_allocation — the offending call site in the site +// field) followed by the debug assert. That check is the standing +// hot-path guardrail for every later sim/render step (roadmap +// README §6, "Global invariants"). Release builds carry no check and +// no crash: a game system that allocates in release degrades through +// the already-logged pool accounting (the pool overflow path, +// pools.h) — never a silent success, never an assert. +// +// --------------------------------------------------------------------------- +// Scope of the counting backend (read before relying on it) +// --------------------------------------------------------------------------- +// +// - Static build trees (the default): the strong operator new +// overrides sit in the executable's link, so an armed window sees +// EVERY heap allocation in the process (engine, pools, game +// systems, test frameworks). +// - Shared build trees: the overrides live inside the laige-core +// image. On POSIX, dynamic linking interposes them process-wide +// (an executable's operator new call resolves to the library's +// definition); on Windows there is no cross-image interposition, +// so an armed window sees the allocations made inside the engine +// images — the engine allocators and the pools, which IS the sim +// loop's storage — but not allocations made in the executable +// itself. The canonical (static) trees give full process coverage +// on every P0 OS. +// - Sanitizer trees (LAIGE_ASAN / LAIGE_TSAN): the sanitizer +// runtimes own operator new/delete, so the counting backend is +// NOT compiled in and this header degrades to inline no-ops +// (allocWatchLive() is false). There, the zero-allocation property +// is verified by the leak-free sanitizer run of the same loop +// plus the pool reservation-delta assertion (the established +// fallback pattern — tests/laige-core, M0-CORE-02/05; +// tests/laige-sim, M1-ECS-03/07). +// - The LAIGE_ALLOC_WATCH=1 compile definition (set on laige-core +// and inherited by every consumer) marks every tree where the +// counting backend is compiled in. +// +// --------------------------------------------------------------------------- +// Cost (PERF-003, DBG-004) +// --------------------------------------------------------------------------- +// +// - Per allocation, window disarmed: one logging-depth load + one +// armed-flag load + two branches (no counter traffic, no +// allocation, no logging). +// - Per allocation, window armed (not inside a diagnostic emit): +// one logging-depth load, one armed-flag load, one fetch_add, and +// one compare-and-swap that fails once the first site is recorded +// (windows are short and allocation-free by contract, so the CAS +// is cheap in practice). +// - Per allocation, inside a diagnostic emit: one logging-depth +// load + one branch (the emission's own work, not the sim loop's +// — the attribution contract). +// +// Attribution (G-R1 measures the SIM LOOP's heap — sim_heap_allocs, +// budgets.json): allocations made by the logging facade while it +// emits an event are NOT attributed to the window. The diagnostic +// subsystem's event memory (rate-state, message formatting, the +// sink) is its own subsystem with its own memory contract (LOG-003 +// bounds HOT-PATH logging separately); a cold-path event the engine +// must log (G-R5 budget overrun, a replay write failure, a +// guardrail warn) degrades loudly, never trips G-R1. The facade's +// emit path wraps its work in detail::LoggingAllocationGuard — +// everything a tick does that is not the diagnostic subsystem's +// emit (a system's local std::vector, engine storage growth, any +// other heap use) still counts and still fails the per-tick assert. +// - Per completed tick, debug builds only: one arm (three atomic +// stores — the first-site, the count, and the armed flag) + one +// read (two atomic loads) — no allocation, no logging on the +// healthy path (LOG-003). Release builds: the entire check is +// compiled out. +// +// Threading (CONC-001): the armed window has exactly one owner — the +// sim owner thread (the simulation is single-threaded, PRD §10.2); +// the loop's tick path is the only caller that arms, so windows never +// nest. The counters are relaxed atomics: an allocation from another +// thread during an armed window is counted (it did happen during the +// tick — the diagnostic says so) but it is observation data, not +// simulation state (ARCH-009). + +#pragma once + +#include +#include + +namespace laige { + +// One armed-window reading (see the header preamble): the heap- +// allocation count since the last arm() and the call site of the +// first offending allocation (nullptr while none). +struct AllocWatchReading { + std::uint64_t allocs{}; // heap allocations since arm() + const void* firstSite{}; // first offending call site (nullptr if none) +}; + +// Start a fresh watch window: reset the window's allocation count and +// clear the first-site capture (see the header preamble for the +// window model, the cost, and the threading contract). O(1), no +// allocation. +void allocWatchArm() noexcept; + +// Read the current armed window (see AllocWatchReading). O(1), no +// allocation. +[[nodiscard]] AllocWatchReading allocWatchRead() noexcept; + +// True when the process-wide counting backend is compiled into this +// build (every non-sanitizer tree); false in the sanitizer trees, +// where the runtimes own operator new/delete and the watch is a +// no-op (the header's scope section). +[[nodiscard]] bool allocWatchLive() noexcept; + +namespace detail { + +// The logging facade's emit-path guard (the attribution contract, +// above). The logging facade wraps each event emission in this guard +// so the diagnostic subsystem's own heap work (rate-state, message +// formatting, the sink) is not attributed to the sim loop's G-R1 +// window. Reentrant (nested emits keep the guard active); O(1), no +// allocation. TEST/ENGINE-INTERNAL: not part of the public API — it +// exists only so the facade (logging.cpp) can mark its own emit work. +class LoggingAllocationGuard { + public: + LoggingAllocationGuard() noexcept; + ~LoggingAllocationGuard() noexcept; + LoggingAllocationGuard(const LoggingAllocationGuard&) = delete; + LoggingAllocationGuard& operator=(const LoggingAllocationGuard&) = + delete; +}; + +} // namespace detail + +#if !defined(LAIGE_ALLOC_WATCH) +// The no-op fallback (the sanitizer trees): the counting backend is +// not compiled in, so an armed window never sees anything. The +// functions are inline no-ops — the engine's per-tick check and the +// test-side probes (tests/**/logging_alloc_counter.h) compile +// unchanged and read zero. +inline void allocWatchArm() noexcept {} +inline AllocWatchReading allocWatchRead() noexcept { + return AllocWatchReading{}; +} +inline bool allocWatchLive() noexcept { return false; } +namespace detail { +inline LoggingAllocationGuard::LoggingAllocationGuard() noexcept {} +inline LoggingAllocationGuard::~LoggingAllocationGuard() noexcept {} +} // namespace detail +#endif + +} // namespace laige diff --git a/src/laige-core/include/laige/logging.h b/src/laige-core/include/laige/logging.h index bd352e6..c822553 100644 --- a/src/laige-core/include/laige/logging.h +++ b/src/laige-core/include/laige/logging.h @@ -57,6 +57,8 @@ #pragma once +#include "laige/alloc_watch.h" // the G-R1 emit-attribution guard (LAIGE_LOG) + #include #include #include @@ -517,9 +519,20 @@ class Logger { do { \ if (::laige::log::Logger::instance().enabled( \ static_cast<::laige::log::Severity>(severity), subsystem)) { \ - ::laige::log::Logger::instance().emit( \ - static_cast<::laige::log::Severity>(severity), subsystem, event, \ - message, ##__VA_ARGS__); \ + /* M1-ALLOC-01 (G-R1 attribution, alloc_watch.h): this emit's \ + own heap work — the field value strings (field() allocates), \ + the rate-state, the sink's message formatting — is the \ + diagnostic subsystem's memory, not the sim loop's. The guard \ + block spans the field argument evaluation (evaluated HERE, \ + before emit's body) through the whole emission, so the \ + per-tick allocation watch never attributes it to the tick's \ + window. */ \ + { \ + ::laige::detail::LoggingAllocationGuard _laige_log_attr_; \ + ::laige::log::Logger::instance().emit( \ + static_cast<::laige::log::Severity>(severity), subsystem, \ + event, message, ##__VA_ARGS__); \ + } \ } \ } while (0) diff --git a/src/laige-core/logging.cpp b/src/laige-core/logging.cpp index 4f702ff..5621106 100644 --- a/src/laige-core/logging.cpp +++ b/src/laige-core/logging.cpp @@ -28,6 +28,8 @@ #include "laige/logging.h" +#include "laige/alloc_watch.h" // the G-R1 emit-attribution guard + #include #include #include @@ -333,6 +335,13 @@ bool Logger::enabled(Severity severity, std::string_view subsystem) const { void Logger::record(Severity severity, std::string_view subsystem, std::string_view event, std::string_view message, std::span fields) { + // M1-ALLOC-01 (G-R1 attribution): this emit's heap work (rate-state, + // the rate-limit summary strings, the sink's message formatting) is + // the diagnostic subsystem's own memory, not the sim loop's — the + // guard marks it so the per-tick allocation watch does not attribute + // it to the tick's window (alloc_watch.h, the attribution contract). + // Everything the tick does that is NOT this emit still counts. + laige::detail::LoggingAllocationGuard guard; if (retired_.load(std::memory_order_relaxed)) return; const auto now = (clock_ != nullptr) ? clock_() diff --git a/src/laige-sim/game_loop.cpp b/src/laige-sim/game_loop.cpp index e5326ae..ec949fc 100644 --- a/src/laige-sim/game_loop.cpp +++ b/src/laige-sim/game_loop.cpp @@ -11,15 +11,24 @@ // exact due computation, the bounded run loop), and up to // maxCatchUpTicks runSystems dispatches — no allocation and no // logging on the success path (PERF-003, LOG-003). The tick_dropped -// warn is cold (an overload episode). +// warn is cold (an overload episode). The M1-ALLOC-01 G-R1 watch +// (debug builds only) adds three atomic stores per completed tick +// (the arm: first-site, count, armed flag) + two atomic loads (the +// read) — no allocation, no logging on the healthy path (the +// alloc/sim_tick_allocation event is cold: it fires only when the +// zero-allocation invariant breaks); release builds compile the +// check out entirely. #include "laige/sim/game_loop.h" // the GameLoop contract (this header) #include #include +#include +#include #include #include +#include "laige/alloc_watch.h" // the G-R1 per-tick watch (M1-ALLOC-01) #include "laige/budget_harness.h" // TimeIt (the per-tick timing, M1-PROF-01) #include "laige/logging.h" #include "laige/sim/profiler.h" // Profiler (Options::profiler) @@ -35,6 +44,27 @@ inline constexpr std::int64_t kNanosecondsPerSecond = 1000000000LL; // The stable subsystem name for game-loop events (LOG-001). inline constexpr const char* kLoopSubsystem = "loop"; +#if !defined(NDEBUG) +// The M1-ALLOC-01 G-R1 event (debug builds only — the per-tick +// zero-allocation check in runOneTick): the subsystem name (LOG-001) +// and the NFR-13.3 5-field-grammar message ({code} | {what} | {why} | +// {fix} | {doc_anchor}), build-stable — the dynamic values are +// structured fields (the tick, the alloc count, the offending call +// site), never message text (the system_timing.cpp precedent). The +// guard mirrors the engine.cpp pattern: a debug-build-only message +// must not trip -Wunused-const-variable in release trees. +inline constexpr const char* kAllocSubsystem = "alloc"; +inline constexpr const char* kSimTickAllocationMessage = + "sim_tick_allocation | a heap allocation occurred inside a " + "completed sim tick | the zero steady-state allocation invariant " + "(G-R1, PRD 8.1) was broken by the tick's work (a system, the " + "onTick hook, the replay recorder, engine storage growth, or a " + "hot-path log) | find the offending allocation's call site (the " + "site field) and move the allocation out of the tick: into a " + "pool, a pre-reserved block, or the setup phase | " + "docs/api/game_loop.md"; +#endif + // The headless clock source (M1-LOOP-01): the monotonic steady_clock // as nanoseconds since its epoch (the windowed clock arrives with // M2-GL-02; the LoggerOptions::clock injection precedent supplies the @@ -227,6 +257,18 @@ Status GameLoop::runTick() noexcept { } Status GameLoop::runOneTick() noexcept { +#if !defined(NDEBUG) + // M1-ALLOC-01 (G-R1): arm the per-tick zero-allocation watch BEFORE + // the tick body (debug builds — the laige/alloc_watch.h contract): + // any heap allocation inside the completed tick (a system, the + // onTick hook, the replay recorder, engine storage growth, even a + // hot-path log) is counted by the process-wide counting backend. + // Release builds: the entire check is compiled out — the pool- + // overflow degradation is already logged through the pool + // accounting (the M1-PROF-01/02 simAllocs frame delta); never a + // crash. + laige::allocWatchArm(); +#endif // The M1-PROF-01 per-tick timing: when a profiler is attached and // enabled, the tick body is wrapped in the M0-CORE-08 TimeIt (two // steady_clock reads) and the measured ms handed to the profiler — @@ -235,14 +277,45 @@ Status GameLoop::runOneTick() noexcept { // The measured sample is a wall-clock diagnostic (ARCH-009) — it // never enters the tick count, the state hash, or a replay. Profiler* prof = options_.profiler; + Status status; if (prof == nullptr || !prof->enabled()) { - return runTick(); + status = runTick(); + } else { + const TimeIt timer; + status = runTick(); + if (status.ok()) { + prof->recordTick(timer.elapsedMs()); + } } - const TimeIt timer; - const Status status = runTick(); +#if !defined(NDEBUG) + // M1-ALLOC-01 (G-R1): check the watch AFTER the completed tick + // (a failed tick ran no systems — the check follows the profiler's + // "a failed tick is not recorded" contract). Cold path: it fires + // only when the invariant is broken — one structured Error event + // carrying the offending call site, then the debug assert + // (FR-12.3: actionable, never silent). Attribution (alloc_watch.h): + // the engine's own cold-path event emission during the tick (a G-R5 + // budget-overrun warn/critical, a replay write failure, a guardrail + // warn) is the diagnostic subsystem's memory, not the sim loop's — + // those events degrade loudly and are never counted here. A tick + // that allocates for any other reason (a system's local std::vector, + // engine storage growth) still fails. if (status.ok()) { - prof->recordTick(timer.elapsedMs()); + const AllocWatchReading watch = laige::allocWatchRead(); + if (watch.allocs != 0) { + LAIGE_LOG_ERROR(kAllocSubsystem, "sim_tick_allocation", + kSimTickAllocationMessage, + laige::log::field("tick", ticks_), + laige::log::field("allocs", watch.allocs), + laige::log::field("site", watch.firstSite)); + assert(watch.allocs == 0 && + "G-R1: a heap allocation occurred inside a completed sim " + "tick — see the alloc/sim_tick_allocation error event " + "(the site field names the offending call site) and " + "docs/api/game_loop.md"); + } } +#endif return status; } diff --git a/src/laige-sim/include/laige/sim/game_loop.h b/src/laige-sim/include/laige/sim/game_loop.h index bba913b..2f005f4 100644 --- a/src/laige-sim/include/laige/sim/game_loop.h +++ b/src/laige-sim/include/laige/sim/game_loop.h @@ -185,6 +185,45 @@ // caller's, not the simulation's) — never authoritative. // // --------------------------------------------------------------------------- +// The zero-allocation check (M1-ALLOC-01, G-R1) +// --------------------------------------------------------------------------- +// +// G-R1 (PRD §9.3, budgets.json sim_heap_allocs: target 0 allocs per +// frame) is enforced in DEBUG builds at the tick boundary: +// runOneTick() arms the process-wide allocation watch +// (laige/alloc_watch.h) before the tick body and reads it after a +// COMPLETED tick. A completed tick with a nonzero window count fails +// with one structured Error event (alloc/sim_tick_allocation — the +// offending call site in the site field) followed by the debug +// assert (FR-12.3: actionable, never silent). The check covers +// everything the tick runs: the systems, the onTick hook, the replay +// recorder, the engine storage growth. Attribution (alloc_watch.h): +// the engine's own cold-path event emission during the tick (a G-R5 +// budget-overrun warn/critical, a replay write failure, a guardrail +// warn) is the diagnostic subsystem's memory, not the sim loop's — +// those documented degradations still log and never trip the assert. +// If a tick allocates for any other reason, the invariant is broken +// and the assert names the site. This is the standing hot-path +// guardrail for every later sim/render step (roadmap README §6, +// "Global invariants"). +// +// Release builds carry NO check and no crash (the guardrail table's +// release column): a game system that allocates in release degrades +// through the already-logged pool accounting — the pool overflow +// path (pools.h) and the per-frame simAllocs delta (M1-PROF-01/02) +// — never silent (CORE-008), never an assert. +// +// Scope of the counting backend: static build trees count every heap +// allocation in the process; shared build trees count the +// allocations made inside the engine images (the engine allocators +// and the pools — the sim loop's storage; POSIX interposes +// process-wide, Windows does not); sanitizer trees compile the watch +// out (the runtimes own operator new/delete — the zero-allocation +// property is then verified by the leak-free sanitizer run plus the +// pool reservation-delta assertion, the established fallback pattern; +// the full scope in laige/alloc_watch.h). +// +// --------------------------------------------------------------------------- // Performance (PERF-002/003) // --------------------------------------------------------------------------- // @@ -206,6 +245,13 @@ // m1-profiler-cost baseline, CORE-001/DBG-004). Profiler null or // disabled: one branch per tick, nothing else. // +// G-R1 watch (M1-ALLOC-01), debug builds only: three atomic stores +// per completed tick (the arm: the first-site, the count, and the +// armed flag) + two atomic loads (the read) — no allocation, no +// logging on the healthy path (the event is cold — it fires only +// when the invariant breaks). Release builds: the check is compiled +// out (the alloc_watch.h cost contract). +// // --------------------------------------------------------------------------- // Threading // --------------------------------------------------------------------------- diff --git a/tests/laige-core/CMakeLists.txt b/tests/laige-core/CMakeLists.txt index ff532ba..a418b69 100644 --- a/tests/laige-core/CMakeLists.txt +++ b/tests/laige-core/CMakeLists.txt @@ -16,17 +16,17 @@ set(LAIGE_CORE_TEST_SOURCES laige-core_tests.cpp result_status_tests.cpp prng_tests.cpp config_json_tests.cpp budget_harness_tests.cpp) -# M0-CORE-02: the test-only allocation counter overrides the global -# operator new/new[]; the sanitizer runtimes define their own new/delete -# (strong symbols in the Clang/GCC TSan runtime archives, interposed by -# ASan), so the counter is excluded from the sanitizer trees. There, the -# step's zero-allocation property is covered by the leak-free sanitizer -# run of the same spam loop plus the LogPerformance timing property — -# the fallback the roadmap names for this step. -if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) - list(APPEND LAIGE_CORE_TEST_SOURCES logging_alloc_counter.cpp) -endif() - +# M1-ALLOC-01: the test-only allocation counter no longer carries its +# own global operator new/new[] overrides — they moved into laige-core +# (src/laige-core/alloc_watch.cpp, the G-R1 counting backend, one +# strong definition per binary), so the test-side probes +# (logging_alloc_counter.h) and the engine's per-tick assertion share +# one counter. LAIGE_ALLOC_COUNTER marks the non-sanitizer trees where +# the backend is live (the same gate as laige-core's LAIGE_ALLOC_WATCH; +# the sanitizer runtimes own operator new/delete, so the backend is +# excluded there — the zero-allocation property is then covered by the +# leak-free sanitizer run of the same loops plus the pool +# reservation-delta assertion, the same fallback pattern). add_executable(laige-core_tests ${LAIGE_CORE_TEST_SOURCES}) if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) target_compile_definitions(laige-core_tests PRIVATE LAIGE_ALLOC_COUNTER=1) diff --git a/tests/laige-core/logging_alloc_counter.cpp b/tests/laige-core/logging_alloc_counter.cpp deleted file mode 100644 index 0fa0510..0000000 --- a/tests/laige-core/logging_alloc_counter.cpp +++ /dev/null @@ -1,74 +0,0 @@ -// Test-only global operator new/new[] overrides (see the header). -// -// A strong definition of the global operator new/new[] in this -// translation unit is linked ahead of the CRT's weak defaults -// (GCC/Clang/AppleClang: the library definitions are weak; MSVC: the -// linker only pulls in a CRT allocator module to resolve undefined -// symbols, which this object already defines). Every heap allocation -// made by any translation unit in the test executable therefore -// passes through the counters below. - -#include "logging_alloc_counter.h" - -#include -#include -#include -#include - -namespace laige::test { - -void resetAllocCounter() { - detail::allocCount.store(0, std::memory_order_relaxed); -} - -std::uint64_t allocCounter() { - return detail::allocCount.load(std::memory_order_relaxed); -} - -} // namespace laige::test - -namespace { - -void count() noexcept { - laige::test::detail::allocCount.fetch_add(1, std::memory_order_relaxed); -} - -} // namespace - -void* operator new(std::size_t size) { - count(); - void* p = std::malloc(size); - if (p == nullptr) std::terminate(); // no exceptions (NFR-8.10) - return p; -} - -void* operator new[](std::size_t size) { - count(); - void* p = std::malloc(size); - if (p == nullptr) std::terminate(); - return p; -} - -void* operator new(std::size_t size, const std::nothrow_t&) noexcept { - void* p = std::malloc(size); - if (p != nullptr) count(); - return p; -} - -void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { - void* p = std::malloc(size); - if (p != nullptr) count(); - return p; -} - -void operator delete(void* p) noexcept { std::free(p); } -void operator delete[](void* p) noexcept { std::free(p); } - -// The sized deallocations too: libstdc++ deallocates through -// operator delete(p, size), and without these overrides the CRT -// defaults would present the free to the sanitizer as a delete of a -// malloc-style allocation (ASan alloc-dealloc-mismatch). Routing them -// through std::free keeps every allocation/deallocation pair -// malloc/free-consistent under the sanitizers. -void operator delete(void* p, std::size_t) noexcept { std::free(p); } -void operator delete[](void* p, std::size_t) noexcept { std::free(p); } diff --git a/tests/laige-core/logging_alloc_counter.h b/tests/laige-core/logging_alloc_counter.h index 4fefaae..f6c6787 100644 --- a/tests/laige-core/logging_alloc_counter.h +++ b/tests/laige-core/logging_alloc_counter.h @@ -1,41 +1,42 @@ -// Test-only process-wide allocation counter (M0-CORE-02). +// Test-only allocation-counter facade (M0-CORE-02; moved to +// laige-core in M1-ALLOC-01). // -// logging_alloc_counter.cpp defines the program's global operator -// new/new[] (the strong definition overrides the CRT's weak default -// for the whole test executable), so every heap allocation made -// anywhere in the process — test framework, engine under test, test -// code — is counted. It is the M0 stand-in for M0-CORE-05's pool -// accounting for this roadmap step's "disabled levels allocate -// nothing" assertion (M0-CORE-05 does not exist yet). +// The process-wide global operator new/new[] overrides that back this +// counter no longer live in this test tree: they moved into +// laige-core (src/laige-core/alloc_watch.cpp, compiled in every +// non-sanitizer tree — the LAIGE_ALLOC_WATCH definition marks it), +// so the engine's per-tick zero-allocation assertion (M1-ALLOC-01, +// G-R1) and the test-side zero-allocation probes share ONE counting +// backend. This header keeps the original test API on top of it: // -// TEST-ONLY: never link this translation unit into an engine library -// or a tool — it would replace the real allocator for that binary. It -// is also excluded from the sanitizer build trees (LAIGE_ASAN/ -// LAIGE_TSAN): the sanitizer runtimes define their own new/delete, so -// the overrides cannot be linked there (see tests/laige-core/ -// CMakeLists.txt; the zero-allocation property is verified in those -// trees by the leak-free runs of the same spam loop plus the timing -// property test). +// - resetAllocCounter() arms a fresh watch window; +// - allocCounter() reads the window's allocation count. +// +// TEST-FACADE ONLY: the counter is a diagnostic, not an API. +// +// Sanitizer trees: the watch is compiled out there (the sanitizer +// runtimes define their own new/delete), LAIGE_ALLOC_COUNTER is not +// defined, and the counter API is unused — the zero-allocation +// properties are verified by the leak-free sanitizer run of the same +// loop plus the pool reservation-delta assertion (the established +// fallback pattern, see tests/laige-core/CMakeLists.txt). #pragma once -#include #include -namespace laige::test { - -namespace detail { - -// The process-wide heap-allocation count (see the file header). -inline std::atomic allocCount{0}; +#include "laige/alloc_watch.h" -} // namespace detail +namespace laige::test { -// Reset the counter to zero. Call it after the test framework has -// finished its startup allocations and before the region under test. -void resetAllocCounter(); +// Reset the counter to zero (start a fresh watch window). Call it +// after the test framework has finished its startup allocations and +// before the region under test. +inline void resetAllocCounter() { laige::allocWatchArm(); } // The number of heap allocations since the last reset. -std::uint64_t allocCounter(); +inline std::uint64_t allocCounter() { + return laige::allocWatchRead().allocs; +} } // namespace laige::test diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index f04cf53..4bd33dd 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,6 +1,6 @@ # laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03 # + M1-LOOP-01/02 + M1-HEAD-01 + M1-CFG-01 + M1-DET-01/02/03 -# + M1-PROF-01/02): +# + M1-ALLOC-01 + M1-PROF-01/02): # entity handle + World entity storage, component type registry, # archetype SoA storage, query API + iteration legality, # deterministic iteration order, the ECS guardrails (G-R3/G-R4), the @@ -47,7 +47,15 @@ # evaluation (the synthetic over-budget system's correct numbers, the # loud NO_ENTRY / NO_SAMPLES semantics), and the engine's opt-in # cached per-run report (the G-R5 events folded into the per-frame -# records, the start validation, the cached-after-shutdown read)). +# records, the start validation, the cached-after-shutdown read)), and +# the zero sim-loop allocation assertion (M1-ALLOC-01: the G-R1 +# per-tick watch — the M1-ECS-07 workload run THROUGH the game loop +# with zero per-tick heap allocations (the engine's per-tick assertion +# enforces it in debug non-sanitizer builds; the suite asserts it +# through the watch where the watch is live), the scratch-system +# failure case (a deliberate std::vector in a system aborts the debug +# build in a forked child; no crash in release/sanitizer builds), and +# the watch API's arm/read/first-site round trip). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus @@ -56,19 +64,20 @@ # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, # `system_timing`, `game_loop`, `presentation`, `engine`, # `game_config`, `determinism_mode`, `replay_record`, `replay_replay`, -# `replay_diff`, `profiler`, and `budget_report` entries +# `replay_diff`, `zero_alloc`, `profiler`, and `budget_report` entries # are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, # M1-ECS-06, M1-ECS-07, M1-SYS-01, M1-SYS-02, M1-SYS-03, M1-LOOP-01, # M1-LOOP-02, M1-HEAD-01, M1-CFG-01, M1-DET-01, M1-DET-02, M1-DET-03, -# M1-DET-05, M1-PROF-01, and M1-PROF-02 Verify commands (`ctest -R -# entity`, `ctest -R component_registry`, `ctest -R archetype`, -# `ctest -R query`, `ctest -R iter_order`, `ctest -R ecs_guardrails`, -# `ctest -R ecs_stress`, `ctest -R system_registry`, `ctest -R -# scheduler`, `ctest -R system_timing`, `ctest -R game_loop`, -# `ctest -R presentation`, `ctest -R engine`, `ctest -R game_config`, -# `ctest -R determinism_mode`, `ctest -R replay_record`, -# `ctest -R replay_replay`, `ctest -R replay_diff`, `ctest -R -# profiler`, and `ctest -R budget_report`), selecting exactly the +# M1-DET-05, M1-ALLOC-01, M1-PROF-01, and M1-PROF-02 Verify commands +# (`ctest -R entity`, `ctest -R component_registry`, `ctest -R +# archetype`, `ctest -R query`, `ctest -R iter_order`, +# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`, `ctest -R +# system_registry`, `ctest -R scheduler`, `ctest -R system_timing`, +# `ctest -R game_loop`, `ctest -R presentation`, `ctest -R engine`, +# `ctest -R game_config`, `ctest -R determinism_mode`, +# `ctest -R replay_record`, `ctest -R replay_replay`, `ctest -R +# replay_diff`, `ctest -R zero_alloc`, `ctest -R profiler`, and +# `ctest -R budget_report`), selecting exactly the # suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp @@ -86,21 +95,22 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp replay_record_tests.cpp replay_replay_tests.cpp replay_diff_tests.cpp + zero_alloc_tests.cpp profiler_tests.cpp budget_report_tests.cpp) -# M1-ECS-03: the test-only allocation counter overrides the global -# operator new/new[]; the sanitizer runtimes define their own -# new/delete (strong symbols in the Clang/GCC TSan runtime archives, -# interposed by ASan), so the counter is excluded from the sanitizer -# trees. There, the zero-allocation properties (the M1-ECS-03 churn -# and the M1-HEAD-01 headless run) are covered by the leak-free +# M1-ALLOC-01: the test-only allocation counter no longer carries its +# own global operator new/new[] overrides — they moved into +# laige-core (src/laige-core/alloc_watch.cpp, the G-R1 counting +# backend, one strong definition per binary), so the test-side +# probes (logging_alloc_counter.h) and the engine's per-tick +# assertion share one counter. LAIGE_ALLOC_COUNTER marks the +# non-sanitizer trees where the backend is live (the same gate as +# laige-core's LAIGE_ALLOC_WATCH; the sanitizer runtimes own +# operator new/delete, so the backend is excluded there — the +# zero-allocation properties are then covered by the leak-free # sanitizer run of the same loops plus the ArchetypeStats -# reservation-delta assertion (the same fallback pattern as +# reservation-delta assertion, the same fallback pattern as # tests/laige-core, M0-CORE-02/05). -if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) - list(APPEND LAIGE_SIM_TEST_SOURCES logging_alloc_counter.cpp) -endif() - add_executable(laige-sim_tests ${LAIGE_SIM_TEST_SOURCES}) if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) target_compile_definitions(laige-sim_tests PRIVATE LAIGE_ALLOC_COUNTER=1) @@ -291,6 +301,19 @@ add_test(NAME replay_diff COMMAND laige-sim_tests --gtest_filter=StateDiff.*:ReplayDiff.*) +# M1-ALLOC-01: the zero sim-loop allocation assertion (G-R1, PRD +# §8.1, PERF-003). The step's Verify command is `ctest -R zero_alloc`; +# this entry selects exactly the ZeroAlloc suites from the shared +# laige-sim_tests executable (the M1-ECS-07 workload run through the +# game loop with the per-tick zero-allocation assertion live, the +# scratch-system failure case — a deliberate std::vector in a system +# aborts the debug build in a forked child — and the watch API's +# arm/read/first-site round trip; the machine-greppable zero-alloc +# window line lands in the ctest output). +add_test(NAME zero_alloc + COMMAND laige-sim_tests + --gtest_filter=ZeroAlloc.*) + # M1-PROF-01: the always-on profiler counters (FR-11.1). The step's # Verify command is `ctest -R profiler`; this entry selects exactly # the Profiler* suites from the shared laige-sim_tests executable (the @@ -374,7 +397,8 @@ if(LAIGE_TSAN) query iter_order ecs_guardrails ecs_stress system_registry scheduler system_timing game_loop presentation engine game_config determinism_mode - replay_record replay_replay replay_diff profiler budget_report + replay_record replay_replay replay_diff zero_alloc profiler + budget_report PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/engine_tests.cpp b/tests/laige-sim/engine_tests.cpp index ca5d809..0de59c9 100644 --- a/tests/laige-sim/engine_tests.cpp +++ b/tests/laige-sim/engine_tests.cpp @@ -333,9 +333,14 @@ TEST(EngineShutdown, ShutdownReleasesTheWorld) { } // --------------------------------------------------------------------------- -// The zero-allocation headless frame path (PERF-003; the M1-ALLOC-01 -// assertion will supersede this probe once it exists — ASan + pool -// accounting is the milestone's interim check) +// The zero-allocation headless frame path (PERF-003, G-R1). The +// M1-ALLOC-01 per-tick watch arms around every tick, so this probe +// reads the LAST completed tick's window after the run (the +// one-shot setup allocations land before the first arm) — and in +// debug non-sanitizer builds the engine's own per-tick assertion +// additionally proves every tick of the run allocated nothing +// (an allocating tick would abort the run). The sanitizer trees +// prove the run leak-free. // --------------------------------------------------------------------------- #if defined(LAIGE_ALLOC_COUNTER) @@ -366,17 +371,18 @@ TEST(EngineRun, HeadlessFramePathAllocatesNothing) { // The run's setup path allocates exactly three times, all one-shot: // the GameLoop object, the PresentationSnapshot object, and the // presentation slot record table (24 B x capacity — the - // presentation.h storage contract). The steady-state frame path - // (clock read, loop frame, snapshot refresh, sleep) touches no - // heap: the count below is identical for 1, 2, 3, and 10 ticks - // (probe-verified, M1-HEAD-01), i.e. zero per frame/per tick — - // the PERF-003 hot-path property. + // presentation.h storage contract). Those land BEFORE the first + // tick's G-R1 watch arm (M1-ALLOC-01), so the window read after + // the run holds the LAST completed tick's allocations: zero — the + // steady-state frame path (clock read, loop frame, snapshot + // refresh, sleep) touches no heap (the PERF-003 hot-path property; + // probe-verified for 1, 2, 3, and 10 ticks, M1-HEAD-01). std::printf("engine-zeroalloc ticks=%llu allocs=%llu\n", static_cast(engine.stats().ticks), static_cast(allocs)); ASSERT_TRUE(status.ok()); EXPECT_EQ(engine.stats().ticks, 3u); - EXPECT_EQ(allocs, 3u); + EXPECT_EQ(allocs, 0u); EXPECT_EQ(sink->entries.size(), 0u); restoreLogger(); } diff --git a/tests/laige-sim/logging_alloc_counter.cpp b/tests/laige-sim/logging_alloc_counter.cpp deleted file mode 100644 index 3a1af76..0000000 --- a/tests/laige-sim/logging_alloc_counter.cpp +++ /dev/null @@ -1,79 +0,0 @@ -// Test-only global operator new/new[] overrides (M1-ECS-03; see the -// header). -// -// A strong definition of the global operator new/new[] in this -// translation unit is linked ahead of the CRT's weak defaults -// (GCC/Clang/AppleClang: the library definitions are weak; MSVC: the -// linker only pulls in a CRT allocator module to resolve undefined -// symbols, which this object already defines). Every heap allocation -// made by any translation unit in the test executable therefore -// passes through the counters below. -// -// (Same pattern as tests/laige-core/logging_alloc_counter.cpp — one -// copy per test executable, since two strong definitions in one -// binary would collide.) - -#include "logging_alloc_counter.h" - -#include -#include -#include -#include - -namespace laige::test { - -void resetAllocCounter() { - detail::allocCount.store(0, std::memory_order_relaxed); -} - -std::uint64_t allocCounter() { - return detail::allocCount.load(std::memory_order_relaxed); -} - -} // namespace laige::test - -namespace { - -void count() noexcept { - laige::test::detail::allocCount.fetch_add(1, std::memory_order_relaxed); -} - -} // namespace - -void* operator new(std::size_t size) { - count(); - void* p = std::malloc(size); - if (p == nullptr) std::terminate(); // no exceptions (NFR-8.10) - return p; -} - -void* operator new[](std::size_t size) { - count(); - void* p = std::malloc(size); - if (p == nullptr) std::terminate(); - return p; -} - -void* operator new(std::size_t size, const std::nothrow_t&) noexcept { - void* p = std::malloc(size); - if (p != nullptr) count(); - return p; -} - -void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { - void* p = std::malloc(size); - if (p != nullptr) count(); - return p; -} - -void operator delete(void* p) noexcept { std::free(p); } -void operator delete[](void* p) noexcept { std::free(p); } - -// The sized deallocations too: libstdc++ deallocates through -// operator delete(p, size), and without these overrides the CRT -// defaults would present the free to the sanitizer as a delete of a -// malloc-style allocation (ASan alloc-dealloc-mismatch). Routing them -// through std::free keeps every allocation/deallocation pair -// malloc/free-consistent under the sanitizers. -void operator delete(void* p, std::size_t) noexcept { std::free(p); } -void operator delete[](void* p, std::size_t) noexcept { std::free(p); } diff --git a/tests/laige-sim/logging_alloc_counter.h b/tests/laige-sim/logging_alloc_counter.h index 6d112ea..2aea958 100644 --- a/tests/laige-sim/logging_alloc_counter.h +++ b/tests/laige-sim/logging_alloc_counter.h @@ -1,51 +1,42 @@ -// Test-only process-wide allocation counter (M1-ECS-03). +// Test-only allocation-counter facade (M1-ECS-03; moved to laige-core +// in M1-ALLOC-01). // -// The M1-ECS-03 churn test asserts its zero-allocation property the -// way M0-CORE-02's logging test did: a strong global -// operator new/new[] override counts every heap allocation in the test -// process, so the churn window's `allocCounter() == 0` is proof that -// the add/remove churn touches no heap — only the pre-reserved SoA -// column blocks (the "pool accounting" is the ArchetypeStats -// reservation delta, asserted alongside in the test). +// The process-wide global operator new/new[] overrides that back this +// counter no longer live in this test tree: they moved into +// laige-core (src/laige-core/alloc_watch.cpp, compiled in every +// non-sanitizer tree — the LAIGE_ALLOC_WATCH definition marks it), +// so the engine's per-tick zero-allocation assertion (M1-ALLOC-01, +// G-R1) and the test-side zero-allocation probes share ONE counting +// backend. This header keeps the original test API on top of it: // -// logging_alloc_counter.cpp defines the program's global operator -// new/new[] (the strong definition overrides the CRT's weak default -// for the whole test executable), so every heap allocation made -// anywhere in the process — test framework, engine under test, test -// code — is counted. +// - resetAllocCounter() arms a fresh watch window; +// - allocCounter() reads the window's allocation count. // -// TEST-ONLY: never link this translation unit into an engine library -// or a tool — it would replace the real allocator for that binary. It -// is also excluded from the sanitizer build trees (LAIGE_ASAN/ -// LAIGE_TSAN): the sanitizer runtimes define their own new/delete, so -// the overrides cannot be linked there (see this directory's -// CMakeLists.txt; the zero-allocation property is verified in those -// trees by the leak-free sanitizer run of the same churn loop plus -// the ArchetypeStats reservation-delta assertion). +// TEST-FACADE ONLY: the counter is a diagnostic, not an API. // -// (Same pattern as tests/laige-core/logging_alloc_counter.{h,cpp} — -// one copy per test executable, since two strong definitions in one -// binary would collide.) +// Sanitizer trees: the watch is compiled out there (the sanitizer +// runtimes define their own new/delete), LAIGE_ALLOC_COUNTER is not +// defined, and the counter API is unused — the zero-allocation +// properties are verified by the leak-free sanitizer run of the same +// loop plus the pool reservation-delta assertion (the established +// fallback pattern, see tests/laige-sim/CMakeLists.txt). #pragma once -#include #include -namespace laige::test { - -namespace detail { +#include "laige/alloc_watch.h" -// The process-wide heap-allocation count (see the file header). -inline std::atomic allocCount{0}; - -} // namespace detail +namespace laige::test { -// Reset the counter to zero. Call it after the test framework has -// finished its startup allocations and before the region under test. -void resetAllocCounter(); +// Reset the counter to zero (start a fresh watch window). Call it +// after the test framework has finished its startup allocations and +// before the region under test. +inline void resetAllocCounter() { laige::allocWatchArm(); } // The number of heap allocations since the last reset. -std::uint64_t allocCounter(); +inline std::uint64_t allocCounter() { + return laige::allocWatchRead().allocs; +} } // namespace laige::test diff --git a/tests/laige-sim/zero_alloc_tests.cpp b/tests/laige-sim/zero_alloc_tests.cpp new file mode 100644 index 0000000..aed7722 --- /dev/null +++ b/tests/laige-sim/zero_alloc_tests.cpp @@ -0,0 +1,650 @@ +// laige-sim zero sim-loop allocation assertion suite (M1-ALLOC-01). +// +// Step scope (roadmap/M1-heartbeat.md, M1-ALLOC-01): +// - The G-R1 debug per-tick assertion (PRD §9.3, budgets.json +// sim_heap_allocs target 0, PERF-003): GameLoop::runOneTick arms +// the process-wide allocation watch (laige/alloc_watch.h) before +// every tick body and checks it after a completed tick — any heap +// allocation inside the tick (a system, the onTick hook, the +// replay recorder, engine storage growth, even a hot-path log) +// fails with one structured Error event +// (alloc/sim_tick_allocation — the offending call site in the +// site field) + the debug assert (FR-12.3: actionable, never +// silent). Release: no check, no crash — the pool-overflow +// degradation is already logged through the pool accounting. +// +// This suite: +// - runs the M1-ECS-07 workload (10k entities, 6 component types, +// 10k frames of add/remove churn + iteration) THROUGH THE GAME +// LOOP as registered systems and proves every tick allocates zero +// heap — in debug non-sanitizer builds the engine's own per-tick +// assertion enforces this (an allocating tick aborts the run), +// and the suite asserts it explicitly through the watch where +// the watch is live (LAIGE_ALLOC_COUNTER); +// - proves the scratch-system failure case (the step's Verify: "a +// std::vector deliberately placed in a scratch system fails the +// assertion"): the deliberate per-tick vector aborts the process +// in debug builds where the watch is live (forked SIGABRT child — +// the entity_tests DestroyStaleAbortsInDebug pattern), and is +// NOT asserted in release or sanitizer builds (no crash — the +// documented degradation path); +// - round-trips the watch API itself (arm, allocate, read the count +// + the first offending site, re-arm resets the window). +// +// Runs as CTest `zero_alloc` (the step's Verify command: +// `ctest -R zero_alloc`). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/sim/entity.h" +#include "laige/sim/game_loop.h" +#include "laige/sim/system.h" + +#include "laige_test_seed.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +#if defined(__unix__) +#include +#include +#include +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "zero_alloc_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "zero_alloc_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "zero_alloc_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// MSVC never updates __cplusplus from /std (it stays 199711L, a legacy +// compatibility value); the active standard is reported by _MSVC_LANG. +// Every other supported compiler (NFR-8.10) sets __cplusplus from -std. +#if defined(_MSC_VER) +# define ZERO_ALLOC_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define ZERO_ALLOC_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if ZERO_ALLOC_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "zero_alloc_tests must be built as C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test component types (global scope on purpose — LAIGE_COMPONENT must +// specialize the primary template in its enclosing namespace) +// --------------------------------------------------------------------------- + +// The M1-ECS-07 workload's six component types (same sizes/alignments +// as the stress suite's — 8 B/8 B/4 B/16 B/8 B-from-4 B/4 B). +struct ZAPos { + std::int32_t x{}; + std::int32_t y{}; +}; +LAIGE_COMPONENT(ZAPos) +// M1-DET-01 (G-R8): integer-only storage (declared I/O of ZAIter). +LAIGE_DETERMINISM_SAFE(ZAPos, std::int32_t, std::int32_t); + +struct ZAVel { + std::int64_t v{}; +}; +LAIGE_COMPONENT(ZAVel) + +struct ZAFlag { + std::int32_t f{}; +}; +LAIGE_COMPONENT(ZAFlag) + +struct ZAQuad { + std::int32_t a{}; + std::int32_t b{}; + std::int32_t c{}; + std::int32_t d{}; +}; +LAIGE_COMPONENT(ZAQuad) + +struct ZAPair { + std::int16_t w{}; + std::int16_t h{}; + std::int16_t p{}; + std::int16_t q{}; +}; +LAIGE_COMPONENT(ZAPair) + +// The churned component: toggled between the base archetypes every +// tick (the add/remove churn of the workload). +struct ZATag { + std::int32_t t{}; // the tick that added it (value churn too) +}; +LAIGE_COMPONENT(ZATag) +// M1-DET-01 (G-R8): integer-only storage (declared I/O of ZAChurn +// and ZAIter). +LAIGE_DETERMINISM_SAFE(ZATag, std::int32_t); + +// The deliberate G-R1 violation (the scratch system of the step's +// Verify case): one heap allocation per tick, destroyed at the end +// of the tick (no leak — the sanitizer trees prove it). +LAIGE_SYSTEM(ZAScratch, 1) +void ZAScratch(laige::World&, laige::SystemContext&) { + std::vector v(16, 1); // the deliberate allocation + volatile std::int32_t sink = v[0]; + static_cast(sink); +} + +namespace { + +// The PRNG substream id for this file (docs/testing.md §4, +// M0-TEST-01): distinct from the archetype suite (1003), the +// iter-order scenario instantiations (1005-1007), and the stress +// suite (1008). +inline constexpr std::uint32_t kZeroAllocSubstreamId = 1012; + +// The M1-ECS-07 workload parameters (the ecs_stress_tests scheme — +// CORE-005: named, justified constants): +// kEntities the PRD §8.1 reference scene size (10k entities). +// kFrames the step's window length ("10k-entity ticks"). +// kWarmupFrames one full 625-frame cohort period (8 cycles x +// 10000/128 picks per cycle) plus margin: every +// archetype's columns reach their high water before +// the window, so the window's zero reservation delta +// is the "pool high-water stable" claim. +// kAddPicks / kRemovePicks 128 ops per tick — within the DEFAULT +// G-R4 budget (kDefaultChurnPerFrameBudget = 256), +// so the window must complete with zero churn warns. +inline constexpr std::uint32_t kEntities = 10000; +inline constexpr std::uint32_t kFrames = 10000; +inline constexpr std::uint32_t kWarmupFrames = 700; +inline constexpr std::uint32_t kAddPicks = 64; +inline constexpr std::uint32_t kRemovePicks = 64; + +// The loop's clock (the game_loop_tests synthetic-clock pattern): 60 +// Hz, one tick's worth of clock per frame -> exactly one tick per +// frame (the accumulator's exact due computation). One 60 Hz tick, +// in whole nanoseconds: 16666667 ns (ceil — 1e9/60 = 16666666.67; +// the floor loses a nanosecond per step and drifts off the exact +// due count over 10k ticks, the game_loop_tests constant). +inline constexpr std::uint32_t kRateHz = 60; +inline constexpr std::int64_t kTickNs = 16666667; + +// FNV-1a 64 (FNV-1a spec constants, fnv.org): the visit checksum. +inline constexpr std::uint64_t kFnvOffset64 = 0xcbf29ce484222325ull; +inline constexpr std::uint64_t kFnvPrime64 = 0x100000001b3ull; + +// The workload's state (file scope on purpose — systems are plain +// functions with no state objects, the FR-1.3 shape; the test TU is +// the state owner). +std::vector gEntities; +std::vector gPermutation; +std::uint32_t gCursor = 0; +std::uint64_t gChecksum = kFnvOffset64; +std::uint64_t gTickCounter = 0; +std::uint64_t gChurnAdds = 0; +std::uint64_t gChurnRemoves = 0; +std::uint64_t gVisits = 0; +bool gChurnOk = true; +bool gIterOk = true; + +std::atomic gClockNs{0}; +std::int64_t clockNow() noexcept { + return gClockNs.load(std::memory_order_relaxed); +} + +// The churn phase of the M1-ECS-07 frame, as a system: kAddPicks adds +// of ZATag (where absent) + kRemovePicks removes (where present) off +// the cyclic permutation — the 128-ops-per-tick scheme (the +// ecs_stress runFrame contract; skips are not counted — the G-R4 +// counting contract, entity.h). 100 ms budget: the workload tick is +// ~1 ms in the -O0 Debug tree, so the declared budget must never +// breach (a budget_overrun warn would itself allocate — the G-R1 +// window must stay log-free). +LAIGE_SYSTEM(ZAChurn, 100) +void ZAChurn(laige::World& world, laige::SystemContext& ctx) { + static_cast(ctx); + const std::int32_t tick = static_cast(gTickCounter); + for (std::uint32_t i = 0; i < kAddPicks && gChurnOk; ++i) { + const std::uint32_t pos = gPermutation[gCursor]; + gCursor = (gCursor + 1) % kEntities; + const laige::Entity e = gEntities[pos]; + if (world.has(e)) continue; // skip, not counted + if (!world.addComponent(e, ZATag{tick}).ok()) { + gChurnOk = false; + } else { + ++gChurnAdds; + } + } + for (std::uint32_t i = 0; i < kRemovePicks && gChurnOk; ++i) { + const std::uint32_t pos = gPermutation[gCursor]; + gCursor = (gCursor + 1) % kEntities; + const laige::Entity e = gEntities[pos]; + if (!world.has(e)) continue; // skip, not counted + if (!world.removeComponent(e).ok()) { + gChurnOk = false; + } else { + ++gChurnRemoves; + } + } + ++gTickCounter; +} + +// The iteration phase of the M1-ECS-07 frame, as a system: one +// each (Read, Read) over every Tagged entity, counting +// visits and folding (slot, generation, tag value) into the 64-bit +// FNV-1a checksum (the ecs_stress scheme). +LAIGE_SYSTEM(ZAIter, 100) +void ZAIter(laige::World& world, laige::SystemContext& ctx) { + static_cast(ctx); + const bool ok = world + .each( + [](laige::Entity e, const ZAPos&, + const ZATag& tag) { + ++gVisits; + gChecksum ^= + static_cast(e.id); + gChecksum *= kFnvPrime64; + gChecksum ^= + static_cast( + e.generation); + gChecksum *= kFnvPrime64; + gChecksum ^= static_cast( + static_cast( + tag.t)); + gChecksum *= kFnvPrime64; + }, + laige::Read{}, laige::Read{}) + .ok(); + if (!ok) gIterOk = false; +} + +// One workload world at exactly its scene budget (setup phase: backing +// allocations and archetype growth allowed — the window under test +// starts after the warm-up): 6 registered types, 10k entities in the +// 4 base archetypes (by index — the ecs_stress setup), the seeded +// pick permutation (docs/testing.md §4: deterministic per seed), and +// the two workload systems. +laige::World makeWorkloadWorld() { + auto w = laige::World::create(laige::World::Options{kEntities}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << kEntities + << ") failed: " << laige::errorName(w.error()); + abort(); + } + laige::World world = std::move(w).takeValue(); + // Setup-phase failures are fatal (ADD_FAILURE + abort — the helper + // returns a value, so no ASSERT_ macros; the ecs_stress makeWorld + // pattern). + if (!world.registerComponent().ok() || + !world.registerComponent().ok() || + !world.registerComponent().ok() || + !world.registerComponent().ok() || + !world.registerComponent().ok() || + !world.registerComponent().ok()) { + ADD_FAILURE() << "component registration failed"; + abort(); + } + EXPECT_EQ(world.componentCount(), 6u); + + gEntities.resize(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) { + auto e = world.create(); + if (!e.ok()) { // the scene fills the budget exactly + ADD_FAILURE() << "world.create() failed at entity " << i; + abort(); + } + gEntities[i] = e.value(); + const std::int32_t base = static_cast(i); + if (!world + .addComponent(gEntities[i], ZAPos{base, -base}) + .ok()) { + ADD_FAILURE() << "addComponent failed at entity " << i; + abort(); + } + switch (i % 4) { + case 0: + if (!world + .addComponent( + gEntities[i], ZAVel{static_cast(i)}) + .ok()) { + ADD_FAILURE() << "addComponent failed at entity " << i; + abort(); + } + break; + case 1: + if (!world + .addComponent( + gEntities[i], ZAVel{static_cast(i)}) + .ok() || + !world + .addComponent(gEntities[i], ZAFlag{base}) + .ok()) { + ADD_FAILURE() << "component add failed at entity " << i; + abort(); + } + break; + case 2: + if (!world + .addComponent( + gEntities[i], ZAQuad{base, base, base, base}) + .ok()) { + ADD_FAILURE() << "addComponent failed at entity " << i; + abort(); + } + break; + case 3: + if (!world + .addComponent( + gEntities[i], + ZAPair{ + static_cast(i), + static_cast(i % 100), + static_cast(i % 50), + static_cast(i % 25)}) + .ok()) { + ADD_FAILURE() << "addComponent failed at entity " << i; + abort(); + } + break; + } + } + // 5 archetypes: the 4 base sets plus the transient {ZAPos} set + // created while the first component of each entity is attached + // (the ecs_stress setup property). + EXPECT_EQ(world.archetypeCount(), 5u); + + // The pick order: a seeded Fisher-Yates permutation (the ecs_stress + // scheme — setup-phase allocation, outside the window). + laige::Prng rng = laige::testing::TestPrng(kZeroAllocSubstreamId); + gPermutation.resize(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) gPermutation[i] = i; + for (std::uint32_t i = kEntities; i > 1; --i) { + const std::uint32_t j = rng.next_range(0, i); // [0, i) + std::swap(gPermutation[i - 1], gPermutation[j]); + } + + if (!world + .registerSystem(ZAChurn_Def, + laige::Io{}) + .ok() || + !world + .registerSystem( + ZAIter_Def, + laige::Io{}, + laige::Io{}) + .ok()) { + ADD_FAILURE() << "system registration failed"; + abort(); + } + return world; +} + +// One loop frame advancing the synthetic clock by exactly one tick +// (the game_loop_tests synthFrame pattern): 1 tick per frame. +bool frameOneTick(laige::GameLoop& loop) { + gClockNs.fetch_add(kTickNs, std::memory_order_relaxed); + return loop.frame().ok(); +} + +// The scratch-system run (the non-asserted branches of the failure +// test): ticks of the deliberate vector allocation must complete +// without an assert (release: the check is compiled out; sanitizer +// debug: the watch is compiled out — the leak-free sanitizer run of +// the same loop is the fallback check). Compiled only where the +// non-asserted branch exists (the debug + live-watch tree uses the +// forked child instead — -Wunused-function would otherwise fire). +#if defined(NDEBUG) || !defined(LAIGE_ALLOC_COUNTER) +bool runScratchTicks(std::uint32_t ticks) { + auto w = laige::World::create(laige::World::Options{8}); + if (!w.ok()) return false; + laige::World world = std::move(w).takeValue(); + if (!world.registerSystem(ZAScratch_Def).ok()) return false; + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) return false; + laige::GameLoop::Options opts; + opts.tickRateHz = kRateHz; + opts.nowNs = &clockNow; + auto r = laige::GameLoop::create(world, sched, opts); + if (!r.ok()) return false; + laige::GameLoop loop = std::move(r).takeValue(); + gClockNs.store(0, std::memory_order_relaxed); + if (!loop.frame().ok()) return false; // start reference (zero ticks) + for (std::uint32_t i = 0; i < ticks; ++i) { + if (!frameOneTick(loop)) return false; + } + return loop.currentTick() == ticks; +} +#endif // NDEBUG || !LAIGE_ALLOC_COUNTER + +} // namespace + +// --------------------------------------------------------------------------- +// The M1-ECS-07 workload THROUGH THE GAME LOOP: 10k entities x 6 +// component types x 10k ticks of add/remove churn + iteration — zero +// heap allocations per tick (G-R1), pool high-water stable +// --------------------------------------------------------------------------- + +TEST(ZeroAlloc, TenKWorkloadThroughTheLoopAllocatesNothing) { + laige::World world = makeWorkloadWorld(); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + + // Warm-up (kWarmupFrames = one full cohort period + margin), run as + // DIRECT world ticks (beginFrame + runSystems, no GameLoop) BEFORE + // the loop starts: the archetype column doublings, the four + // Tagged transient archetypes, and the entity-budget level events + // are setup-phase growth (the ecs_stress scheme) — outside the + // per-tick G-R1 window. By the time the loop runs, every column is + // at its high water and the window's churn moves only between the + // pre-reserved blocks. + for (std::uint32_t f = 0; f < kWarmupFrames; ++f) { + world.beginFrame(); // void noexcept (entity.h) + if (!world.runSystems(sched).ok()) { + ADD_FAILURE() << "warm-up tick " << f << " failed"; + break; + } + } + + laige::GameLoop::Options opts; + opts.tickRateHz = kRateHz; + opts.nowNs = &clockNow; + auto loopR = laige::GameLoop::create(world, sched, opts); + ASSERT_TRUE(loopR.ok()); + laige::GameLoop loop = std::move(loopR).takeValue(); + gClockNs.store(0, std::memory_order_relaxed); + ASSERT_TRUE(loop.frame().ok()); // start reference (zero ticks) + + const laige::ArchetypeStats before = world.archetypeStats(); + + // The measured window: 10k ticks, exactly one per frame. +#if defined(LAIGE_ALLOC_COUNTER) + // Per-tick windows (the M1-ALLOC-01 model): the engine's per-tick + // arm resets the watch at the start of each tick, so the counter + // read after a frame is exactly that tick's allocation count. + std::uint64_t windowAllocs = 0; +#endif + bool windowOk = true; + for (std::uint32_t f = 0; f < kFrames; ++f) { + if (!frameOneTick(loop)) { + ADD_FAILURE() << "window frame " << f << " failed"; + windowOk = false; + break; + } +#if defined(LAIGE_ALLOC_COUNTER) + windowAllocs += laige::test::allocCounter(); +#endif + } + const laige::ArchetypeStats after = world.archetypeStats(); + ASSERT_TRUE(windowOk); + // The loop ran exactly the window's ticks (the warm-up was direct + // world ticks, before the loop existed). + EXPECT_EQ(loop.currentTick(), static_cast(kFrames)); + + // Sanity: the workload actually churned (a silent no-op system + // would make the zero-allocation claim vacuous). + EXPECT_TRUE(gChurnOk); + EXPECT_TRUE(gIterOk); + ASSERT_GT(gChurnAdds + gChurnRemoves, 0u); + ASSERT_GT(gVisits, 0u); + + // Pool high-water stability: the window reserved nothing new — the + // churn moved only between the pre-reserved column blocks (the + // reserve policy, archetype.h; the ecs_stress window claim). Only + // ZATag was toggled: every entity keeps its base components. + EXPECT_EQ(after.totalReservations, before.totalReservations); + EXPECT_EQ(after.totalArchetypeGrowth, before.totalArchetypeGrowth); + EXPECT_EQ(after.rowsLive, kEntities); + EXPECT_EQ(world.entityCount(), kEntities); + +#if defined(LAIGE_ALLOC_COUNTER) + // The window's zero-allocation property (non-sanitizer trees): + // every tick's window read zero heap allocations. In debug builds + // the engine's own per-tick G-R1 assertion additionally proves the + // property for every tick of the run (an allocating tick would have + // aborted this run before the reads). The sanitizer trees prove the + // same property with the leak-free sanitizer run of the same loop + // plus the reservation delta above (the established fallback). + EXPECT_EQ(windowAllocs, 0u); +#endif + + // Machine-greppable line for the record (CORE-001 / AGENTS §12: + // the measured property, on every ctest run). + std::printf( + "zero-alloc window: ticks=%llu churn_adds=%llu churn_removes=%llu " + "visits=%llu allocs=%llu reservations_delta=%llu " + "checksum=0x%016llx\n", + static_cast(loop.currentTick()), + static_cast(gChurnAdds), + static_cast(gChurnRemoves), + static_cast(gVisits), +#if defined(LAIGE_ALLOC_COUNTER) + static_cast(windowAllocs), +#else + 0ULL, +#endif + static_cast( + after.totalReservations - before.totalReservations), + static_cast(gChecksum)); + std::fflush(stdout); +} + +// --------------------------------------------------------------------------- +// The scratch-system failure case: a deliberate std::vector in a +// registered system fails the per-tick assertion (debug + live watch) +// and is not asserted (release / sanitizer: no crash — the +// degradation path) +// --------------------------------------------------------------------------- + +TEST(ZeroAlloc, ScratchSystemAllocationFailsTheTickAssertion) { +#if defined(NDEBUG) + // Release: the G-R1 check is compiled out (the game_loop.h + // "zero-allocation check" contract) — an allocating game system does + // not crash a release build; the degradation is the already-logged + // pool accounting. The ticks must still complete cleanly. + EXPECT_TRUE(runScratchTicks(5)); +#elif !defined(LAIGE_ALLOC_COUNTER) + // Sanitizer debug: the counting backend is compiled out (the + // runtimes own operator new/delete) — the assertion cannot fire + // here; the leak-free sanitizer run of the same loop is the + // fallback check. The ticks must still complete cleanly. + EXPECT_TRUE(runScratchTicks(5)); +#else +#if defined(__unix__) + // Debug + live watch: the per-tick assertion must fire. Exercised + // in a forked child so the test process survives (the entity_tests + // DestroyStaleAbortsInDebug pattern): the child must die on + // SIGABRT inside the first scratch tick. + const pid_t pid = fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + // Child: one scratch tick — the completed tick's window holds the + // vector's allocation, so the G-R1 assert must fire. + auto w = laige::World::create(laige::World::Options{8}); + if (!w.ok()) _exit(117); + laige::World world = std::move(w).takeValue(); + if (!world.registerSystem(ZAScratch_Def).ok()) _exit(117); + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) _exit(117); + laige::GameLoop::Options opts; + opts.tickRateHz = kRateHz; + opts.nowNs = &clockNow; + auto r = laige::GameLoop::create(world, sched, opts); + if (!r.ok()) _exit(117); + laige::GameLoop loop = std::move(r).takeValue(); + gClockNs.store(0, std::memory_order_relaxed); + if (!loop.frame().ok()) _exit(117); // start reference + gClockNs.store(kTickNs, std::memory_order_relaxed); + (void)loop.frame(); // tick 1 completes with an allocation -> assert + _exit(1); // unreachable: the parent fails below without the assert + } + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + EXPECT_TRUE(WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT) + << "expected the G-R1 tick-allocation assert to abort the child " + "(SIGABRT)"; +#else + GTEST_SKIP() << "fork() is not available on Windows; the G-R1 " + "tick-allocation assert is exercised on the POSIX jobs."; +#endif +#endif +} + +// --------------------------------------------------------------------------- +// The watch API itself: arm, allocate, read the count + the first +// offending site, re-arm resets the window +// --------------------------------------------------------------------------- + +TEST(ZeroAlloc, WatchCountsAllocationsAndCapturesTheFirstSite) { +#if !defined(LAIGE_ALLOC_COUNTER) + // Sanitizer trees: the counting backend is compiled out (the + // runtimes own operator new/delete) — the watch is a no-op there + // (the fallback pattern, alloc_watch.h scope section). + GTEST_SKIP() << "the counting backend is compiled out in sanitizer " + "trees (allocWatchLive() is false)"; +#else + EXPECT_TRUE(laige::allocWatchLive()); + laige::allocWatchArm(); + const laige::AllocWatchReading empty = laige::allocWatchRead(); + EXPECT_EQ(empty.allocs, 0u); + EXPECT_EQ(empty.firstSite, nullptr); + { + std::vector v(4, 7); // one heap allocation + volatile std::int32_t sink = v[0]; + static_cast(sink); + } + const laige::AllocWatchReading after = laige::allocWatchRead(); + EXPECT_GE(after.allocs, 1u); + EXPECT_NE(after.firstSite, nullptr); // the actionable call site + // A re-arm resets the window (the per-tick window model). + laige::allocWatchArm(); + const laige::AllocWatchReading reset = laige::allocWatchRead(); + EXPECT_EQ(reset.allocs, 0u); + EXPECT_EQ(reset.firstSite, nullptr); +#endif +} From aaab353f46f9fb2007f5329504076a098d669490 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Thu, 24 Sep 2026 12:26:30 +0200 Subject: [PATCH 2/5] Record commit 4564029 in the M1-ALLOC-01 changelog row (PR #50) --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 9696d7e..913c8f6 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -216,7 +216,7 @@ One line per completed (or split/renumbered) step. | 2026-09-21 | M1-CFG-01 | `—` | Declarative game config (FR-1.5, ARCH-007, ADR 0002/0003, M1-CFG-01 scope, nothing else): the version 1 `config.json` schema in a new public header `src/laige-sim/include/laige/sim/config.h` (+ `config.cpp`) — `EngineConfig` MOVED from engine.h (the five original members keep their order, so existing aggregate initializers compile unchanged) gains the `budgets` block (system_time_default_ms, draw_calls_per_frame, particles_per_frame — declared values before their M2 consumers), the `camera` block (fov_degrees, zoom, follow_lerp_per_sec — stored, consumed M2), and `asset_roots` (non-empty strings; no existence check — the M2 asset pipeline owns it); the REQUIRED `version` key gates before every other key (missing → `config/version_missing`, non-integer → `config/version_invalid`, ≠ 1 → `config/version_unsupported` — the provisional M1-HEAD-01 documents migrate by adding `"version": 1`); unknown keys at any level warn `config/unknown_key` and are ignored (forward-compat); first failure wins in document order; every rejection is a rate-limited warn with the NFR-13.3 5-field text (one named constant per rejection, LOG-002) + `InvalidArgument`; `loadGameConfig(path)` (bounded 1 MiB read + the M0-CORE-07 parse + the schema) replaces the CLI's read/parse sequence (exit codes unchanged); `EngineConfigOverride` + `applyConfigOverride` (the FR-1.5 programmatic override-of-a-subset merge: per-leaf optionals, each set field validated in its documented domain, first set field that fails wins, value semantics, `assetRoots` replacement); `ConfigHotReloader` (move-only, no thread, caller-driven poll — debug builds ONLY, the replay/`record_disabled` pattern: release builds reject with `config/hot_reload_disabled`): baseline bytes + validated baseline, byte compare per poll, non-sim changes (camera.*, draw/particle budgets, asset_roots) apply in place + `config/hot_reload_applied` (Info, keys named) + baseline advance, sim-affecting changes (version, tick_rate_hz, entity_budget, churn_per_frame_budget, seed, determinism.*, budgets.system_time_default_ms) refused ATOMICALLY + `config/hot_reload_rejected` (Error, first key + old/new) with config/baseline untouched, read/parse/schema failures keep the previous config (`config/hot_reload_read_failed` or the loader's event), moved-from poll fails without logging (stopped-state precedent); the replay identity's `configHash` covers only the sim-affecting fields — the declared presentation values are deliberately excluded, so the encoding (tag 1) is UNCHANGED and every committed baseline/replay log stays valid (replay.h/.cpp comments updated); docs: NEW `docs/api/config.md` (key table, versioning + migration, rejection table, the override API, the hot-reload contract, Performance, misuse), engine.md config section rewritten to the final surface, replay.md/determinism.md/testing.md/docs-README/sim-README cross-refs updated; the provisional config surface in engine.h/engine.cpp folded into config.h/config.cpp (engine.h includes config.h; `Engine::create` re-validates the tick rate with the shared `kConfigTickRateInvalidMessage`); fixtures gain `"version": 1` (headless_smoke.json, samples/hello/config.json, the three replay smoke fixtures); NEW `game_config` CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path — `ConfigHotReload.*`); engine_tests drops the migrated `EngineConfigParse.*` (the suites live in game_config_tests.cpp); determinism_tests' config docs gain `"version": 1`; `laige-api.json` regenerated (734 symbols); local Verify: `ctest -R config` green (config_json + game_config + hello_config_valid), full `ctest` 88/88 on `build` (Debug g++), `build-asan` (leak-free), `build-release` (NDEBUG — the `hot_reload_disabled` path exercised), `build-clang`, `build-tsan` (config suites `halt_on_error=1`), `build-shared`; zero new warnings under NFR-8.10; untested: the MSVC `_fsopen` branch (CI-only, the M1-DET-02 precedent). Size: larger than the ~300-line guidance (the message table + hot reloader + 38-test suite) — noted here per the roadmap's split rule, not split | | 2026-09-23 | M1-PROF-02 | `0c7cf5c` / PR #49 | Frame graph / budget report (FR-11.2, PRD §9.1 S-6, §9.3 G-R5; M1-PROF-02 scope, nothing else): the `FrameBudgetRecorder` fixed 32-frame ring (`src/laige-sim/include/laige/sim/frame_budget.h` + `frame_budget.cpp` — `FrameBudgetRecord` per completed frame: the 0-based frame index, the tick delta, the frame's sim work ms, the pool-reservations sim-alloc delta, the G-R5 `budget_overrun`/`budget_critical` event deltas; `recordFrame` O(1) allocation-free hot path, `at(i)` oldest-first over the wrapping ring, `totalFrames()` keeps counting past the window — no silent truncation, CORE-008) and `buildFrameBudgetReport` (cold format pass, the AGENTS §12 field format): every DECLARED budget measured vs declared with a pass/flag — each system's declared `SystemDef::budgetMs` (fpx16_16, exact — ADR 0002) vs its M1-SYS-03 rolling window's **p99** (the G-R5 sustained-overrun signal; single recovered overruns stay visible in the per-frame records + the `warns`/`errors` counters), `sim_tick_avg`/`sim_tick_p99` (budgets.json, M0-CORE-08) vs the `Profiler`'s tick window (mean/p99), `sim_heap_allocs` (the PRD §8.1 hard-zero budget) vs the per-frame sim-alloc deltas (max); the M0-CORE-08 `budgetCheck` blocks embedded verbatim; a missing entry is a loud `NO_ENTRY`, an empty window a loud `NO_SAMPLES` (a zero-tick run is never silent), the over-budget systems list ascending id, and `overall=PASS|FAIL` (a broken harness folds to FAIL — CORE-008); the ENGINE wiring (always-on per-frame accumulation — two O(1) reads + two O(systemCount) G-R5 counter passes + one O(1) ring write per frame, no allocation, no logging, PERF-003/LOG-003; the run-setup allocation count stays exactly three — `HeadlessFramePathAllocatesNothing` still pins it — the ring is created in `Engine::create`): `startBudgetReport(budgetsPath, lastNFrames)` (EVERY build — diagnostics, not replay state; loads the table now, builds + CACHES the report at the run's end on EVERY path — readable after the shutdown, the `profileStats()` precedent; a budget FAIL never fails the run — CORE-002; errors: stopped/empty-path/double-start `InvalidArgument`, load `IoError`/`MalformedInput` + `budget/report_*` structured events), `budgetReportRequested()`, `lastBudgetReport()`; the CLI surface (`tools/run/laige-run.cpp`): `--budget-report [N]` (1..32; omitted = all retained; printed to stdout after the profile summary line, even on a failed run), `--budgets ` (flag → `LAIGE_BUDGETS_PATH` env → `budgets.json` CWD — the laige-bench resolution order), `--fail-on-budget` (**exit 3** when the run COMPLETED but `overall=FAIL` — the PRD §8.1 CI gate; run failure stays exit 1, start/load failure exit 2); 14-test `budget_report` CTest entry (`tests/laige-sim/budget_report_tests.cpp`: the ring's newest-frames-oldest-first semantics, the SYNTHETIC OVER-BUDGET SYSTEM with correct numbers — the 2 ms burn vs a 0.1 ms fpx16_16 budget (20× — both G-R5 multipliers exceeded on the nominal floor, so the exact `runs=10 warns=10 errors=10` and p99 ≥ 2 assertions are preemption-tolerant): the declared budget echoed, the `over_budget:` line, `overall=FAIL`, the per-frame FAIL invariants — the engine's cached-after-shutdown report, the G-R5 events folded into the per-frame records (rate-limiting-off capture sink: 10 warns + 10 criticals exact), the healthy-world PASS, the loud NO_ENTRY/NO_SAMPLES, the start validation (double/empty/stopped/unreadable/malformed), and the record path's zero-allocation (`budget-report-recorder-zeroalloc frames=1000 allocs=0`, non-sanitizer trees)); `laige_run_budget` CLI smoke (every P0 OS job: 30-tick run, `--budget-report 4 --budgets /budgets.json --fail-on-budget`, passes iff `overall=PASS` + exit 0); sample report committed as `tests/laige-sim/fixtures/budget_report_sample.txt` (a sample, NOT a golden — the report carries wall-clock values); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostics — never enter sim state, hashes, or replays, ARCH-009; `tools/laige-determinism-lint` green); `laige-api.json` regenerated (24 headers; +`FrameBudgetRecord`/`kFrameBudgetWindow`/`FrameBudgetRecorder`/`FrameBudgetReportOptions`/`FrameBudgetReport`/`buildFrameBudgetReport` + the three `Engine` methods + `Profiler::tickWindow`); docs in the same change: `docs/api/frame_budget.md` (new — the declared budgets, the report format + pass/flag semantics, the Engine surface, the CLI + exit 3, Performance, determinism, Testing/CI) + engine.md (CLI flags, exit 3, per-frame cost, misuse, Testing) + profiler.md cross-ref + the docs/README + sim README indexes; local Verify: canonical g++ Debug tree zero-warning, `ctest -R budget_report` green (14/14), `ctest -R laige_run_budget` green, `ctest -R profiler`/`engine`/`system_timing` green (the zero-allocation pin unchanged), both lints OK | | 2026-09-21 | M1-PROF-01 | `a811297` / PR #47 | Always-on profiler counters (FR-11.1, DBG-008; M1-PROF-01 scope, nothing else): the `Profiler` core (`src/laige-sim/include/laige/sim/profiler.h` + `profiler.cpp`) — always-on, fixed-storage, no allocation after init: tick/frame time rolling windows (512/256 samples, M0-CORE-08 `Histogram`; record is O(1) allocation-free, drops the OLDEST, the since-construction counters keep counting), draw calls / texture binds / net bytes counters (0 in headless M1 — the fields exist per FR-11.1, the render/network subsystems feed them in M2/M3), and the cold `snapshot()` (own counters; `snapshot(world)` adds the world-pulled fields — entity total/alive/capacity, sim alloc count = `World::archetypeStats().totalReservations` (the M1-ECS-03 pool accounting, target 0), system count — read COLD, never copied; per-system windows stay in `World`, pulled only in the report); move-only (moved-from = stopped: records no-op, empty snapshot); `setEnabled`/`enabled` (disabled = one branch, no recording); report surface: `formatProfileSummaryLine` (the CLI one-liner), `formatProfileText` / `formatProfileJson` (version-1 schema: counters, tick/frame windows (n==0 → `n=0` text / JSON `null`, never NaN), world fields, per-system entries with the M1-SYS-03 window stats), `writeProfile` (truncating write, no partial file on failure, `Result` — `IoError`); wiring: `GameLoop::Options::profiler` (non-owning) — `runOneTick` times the tick body (the frame's `beginFrame` + one `runSystems` dispatch) with the M0-CORE-08 `TimeIt` and records on SUCCESS only (a failed tick is neither counted nor recorded), null/disabled = one branch; the engine owns the profiler (`Engine::create` constructs it — engine setup, not run setup, so the "exactly three one-shot allocations per run" claim stays true; the run loop adds the frame feed — two clock reads + one ring write per frame, excluding the pacing sleep, first frame not recorded, failed frames not recorded; `shutdown()` releases it in the pools step, abandoning a started-but-unfinalized report with `profiler/report_aborted`); the per-run report: `startProfileReport(path)` (EVERY build — diagnostics, not replay state, no `NDEBUG` gate), written at the END of the run on EVERY path (a zero-tick run writes a zero-tick report, CORE-008), a write failure does NOT fail the run (sticky `profileReportStatus()`, `profiler/report_write_failed` Error), `profileStats()` = the last run's cached snapshot (the world is released in shutdown); `laige-run --prof-out ` (start failure exits 2; a write failure leaves the run `status=ok` and exits 2) + the always-printed `laige-run profile: …` one-line summary (the byte-stable `status=ok` line untouched — the summary is a separate stdout line); structured events (subsystem `profiler`, NFR-13.3 5-field grammar): `report_started` (Info), `report_written` (Info), `report_write_failed` (Error), `report_aborted` / `report_already_started` / `report_path_invalid` (Warn); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostic — never enters sim state, hashes, or replays, ARCH-009); 26-test `profiler` CTest entry (`tests/laige-sim/profiler_tests.cpp`: exact percentiles 1..100 → p50=50/p95=95/p99=99/mean=50.5, rollover cap, independent windows, zero-capacity drop, adders, disabled no-op + preserved state, moved-from stop, cold snapshot, per-completed-tick timing (failed tick unrecorded), the engine's per-run cache + report (written on every run path, version-1 JSON parseable, double-start/empty-path/stopped-engine rejections, write failure sticky without failing the run, pre-run shutdown abandonment with no file on disk), the greppable text form, the record path's zero-allocation (`profiler-zeroalloc ticks=1000 allocs=0`, non-sanitizer trees), and the enabled-cost gate ON vs OFF over 10k-entity ticks ≤ 1% (`profiler-cost on_p50=0.557288 off_p50=0.555746 overhead_pct=0.277465`, best-of-2 per arm, non-sanitizer trees — the LAIGE_ALLOC_COUNTER gate: sanitizer instrumentation inflates the fixed per-tick cost, 1.46% on the ASan tree)); docs in the same change: `docs/api/profiler.md` (new) + engine.md / game_loop.md updates + the docs/README, sim README, debugging README, tools README indexes; baseline `docs/benchmarks/baselines/m1-profiler-cost.md` (full AGENTS §12 metadata, verbatim runs — measured +0.28% vs the 1% gate); local Verify: canonical g++ tree zero-warning, full `ctest` 89/89 (the new `profiler` entry + `api-real-tree` + `determinism-lint-real-tree` green after the manifest regeneration), `laige-run --prof-out` smoke (the summary line + the valid JSON report on disk); cross-tree builds + `tools/laige-include-lint` in the PR branch | -| 2026-09-24 | M1-ALLOC-01 | `feat/m1-alloc-01-zero-alloc-assert` | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `__return_address` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK | +| 2026-09-24 | M1-ALLOC-01 | `4564029` / PR #50 | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `__return_address` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK | --- From 06f1a6f2802952d552fe7ef1ea8bd576d7ae0e6e Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Thu, 24 Sep 2026 12:28:41 +0200 Subject: [PATCH 3/5] M1-ALLOC-01: align two stale comments with the attribution contract The game_loop.cpp arm-site comment and the alloc_watch.h preamble still described the pre-attribution behavior (hot-path log counted, one disarmed-load cost). Both now state the attribution contract and the two-load disarmed cost. --- src/laige-core/include/laige/alloc_watch.h | 10 ++++++---- src/laige-sim/game_loop.cpp | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/laige-core/include/laige/alloc_watch.h b/src/laige-core/include/laige/alloc_watch.h index e21029b..d0fd456 100644 --- a/src/laige-core/include/laige/alloc_watch.h +++ b/src/laige-core/include/laige/alloc_watch.h @@ -25,10 +25,12 @@ // new/new[] (alloc_watch.cpp, compiled in every non-sanitizer build // tree): while a window is armed, every heap allocation made by ANY // translation unit — engine storage, the pools, a system's local -// std::vector, even a hot-path log — increments the window count, and -// the caller's return address of the first offending allocation is -// captured (the actionable call site, FR-12.3). While no window is -// armed, each allocation pays exactly one atomic load + one branch. +// std::vector — increments the window count, and the caller's return +// address of the first offending allocation is captured (the +// actionable call site, FR-12.3). The one exclusion is the diagnostic +// subsystem's own emit (the attribution contract, below): the logging +// facade marks its own work while it emits an event. While no window +// is armed, each allocation pays two atomic loads + two branches. // // --------------------------------------------------------------------------- // The per-tick assertion (the laige-sim half of M1-ALLOC-01) diff --git a/src/laige-sim/game_loop.cpp b/src/laige-sim/game_loop.cpp index ec949fc..e19a536 100644 --- a/src/laige-sim/game_loop.cpp +++ b/src/laige-sim/game_loop.cpp @@ -261,8 +261,10 @@ Status GameLoop::runOneTick() noexcept { // M1-ALLOC-01 (G-R1): arm the per-tick zero-allocation watch BEFORE // the tick body (debug builds — the laige/alloc_watch.h contract): // any heap allocation inside the completed tick (a system, the - // onTick hook, the replay recorder, engine storage growth, even a - // hot-path log) is counted by the process-wide counting backend. + // onTick hook, the replay recorder, engine storage growth) is + // counted by the process-wide counting backend — except the + // diagnostic subsystem's own emit, which is attributed to the + // logging facade (the attribution contract, alloc_watch.h). // Release builds: the entire check is compiled out — the pool- // overflow degradation is already logged through the pool // accounting (the M1-PROF-01/02 simAllocs frame delta); never a From c9587fa8d1c046a64799cece4c4f62aaf0a907fb Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Thu, 24 Sep 2026 12:35:01 +0200 Subject: [PATCH 4/5] Regenerate laige-api.json: comment edits shifted alloc_watch.h symbol lines --- laige-api.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/laige-api.json b/laige-api.json index 22b1414..4d861d9 100644 --- a/laige-api.json +++ b/laige-api.json @@ -29,15 +29,15 @@ "src/laige-sim/include/laige/sim/system.h" ], "symbols": [ - {"name": "laige::AllocWatchReading", "kind": "struct", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 130, "signature": "struct AllocWatchReading", "summary": "One armed-window reading (see the header preamble): the heap- allocation count since the last arm() and the call site of the first offending allocation (nullptr while none).", "budget": null, "experimental": false}, - {"name": "laige::AllocWatchReading::allocs", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 131, "signature": "std::uint64_t allocs{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::AllocWatchReading::firstSite", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 132, "signature": "const void* firstSite{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 139, "signature": "void allocWatchArm() noexcept", "summary": "Start a fresh watch window: reset the window's allocation count and clear the first-site capture (see the header preamble for the window model, the cost, and the threading contract). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 143, "signature": "[[nodiscard]] AllocWatchReading allocWatchRead() noexcept", "summary": "Read the current armed window (see AllocWatchReading). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 149, "signature": "[[nodiscard]] bool allocWatchLive() noexcept", "summary": "True when the process-wide counting backend is compiled into this build (every non-sanitizer tree); false in the sanitizer trees, where the runtimes own operator new/delete and the watch is a no-op (the header's scope section).", "budget": null, "experimental": false}, - {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 177, "signature": "inline void allocWatchArm() noexcept", "summary": "The no-op fallback (the sanitizer trees): the counting backend is not compiled in, so an armed window never sees anything. The functions are inline no-ops — the engine's per-tick check and the test-side probes (tests/**/logging_alloc_counter.h) compile unchanged and read zero.", "budget": null, "experimental": false}, - {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 178, "signature": "inline AllocWatchReading allocWatchRead() noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 181, "signature": "inline bool allocWatchLive() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading", "kind": "struct", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 132, "signature": "struct AllocWatchReading", "summary": "One armed-window reading (see the header preamble): the heap- allocation count since the last arm() and the call site of the first offending allocation (nullptr while none).", "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading::allocs", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 133, "signature": "std::uint64_t allocs{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::AllocWatchReading::firstSite", "kind": "variable", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 134, "signature": "const void* firstSite{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 141, "signature": "void allocWatchArm() noexcept", "summary": "Start a fresh watch window: reset the window's allocation count and clear the first-site capture (see the header preamble for the window model, the cost, and the threading contract). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 145, "signature": "[[nodiscard]] AllocWatchReading allocWatchRead() noexcept", "summary": "Read the current armed window (see AllocWatchReading). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 151, "signature": "[[nodiscard]] bool allocWatchLive() noexcept", "summary": "True when the process-wide counting backend is compiled into this build (every non-sanitizer tree); false in the sanitizer trees, where the runtimes own operator new/delete and the watch is a no-op (the header's scope section).", "budget": null, "experimental": false}, + {"name": "laige::allocWatchArm", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 179, "signature": "inline void allocWatchArm() noexcept", "summary": "The no-op fallback (the sanitizer trees): the counting backend is not compiled in, so an armed window never sees anything. The functions are inline no-ops — the engine's per-tick check and the test-side probes (tests/**/logging_alloc_counter.h) compile unchanged and read zero.", "budget": null, "experimental": false}, + {"name": "laige::allocWatchRead", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 180, "signature": "inline AllocWatchReading allocWatchRead() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::allocWatchLive", "kind": "function", "header": "src/laige-core/include/laige/alloc_watch.h", "line": 183, "signature": "inline bool allocWatchLive() noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::HistogramStats", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 155, "signature": "struct HistogramStats", "summary": "Summary statistics over the samples currently stored in a Histogram (rolling window). When n == 0 the six statistics are NaN (check n; budgetCheck turns an empty histogram into a loud NO_SAMPLES failure).", "budget": null, "experimental": false}, {"name": "laige::HistogramStats::n", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 156, "signature": "std::uint64_t n", "summary": null, "budget": null, "experimental": false}, {"name": "laige::HistogramStats::min", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 157, "signature": "double min", "summary": null, "budget": null, "experimental": false}, From cd8442c71d523af7d23290a2748028e91d34bd6e Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Thu, 24 Sep 2026 12:45:24 +0200 Subject: [PATCH 5/5] Record the M1-ALLOC-01 CI observation in the changelog row (PR #50, run 35988075605 green) --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index 913c8f6..987f54c 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -216,7 +216,7 @@ One line per completed (or split/renumbered) step. | 2026-09-21 | M1-CFG-01 | `—` | Declarative game config (FR-1.5, ARCH-007, ADR 0002/0003, M1-CFG-01 scope, nothing else): the version 1 `config.json` schema in a new public header `src/laige-sim/include/laige/sim/config.h` (+ `config.cpp`) — `EngineConfig` MOVED from engine.h (the five original members keep their order, so existing aggregate initializers compile unchanged) gains the `budgets` block (system_time_default_ms, draw_calls_per_frame, particles_per_frame — declared values before their M2 consumers), the `camera` block (fov_degrees, zoom, follow_lerp_per_sec — stored, consumed M2), and `asset_roots` (non-empty strings; no existence check — the M2 asset pipeline owns it); the REQUIRED `version` key gates before every other key (missing → `config/version_missing`, non-integer → `config/version_invalid`, ≠ 1 → `config/version_unsupported` — the provisional M1-HEAD-01 documents migrate by adding `"version": 1`); unknown keys at any level warn `config/unknown_key` and are ignored (forward-compat); first failure wins in document order; every rejection is a rate-limited warn with the NFR-13.3 5-field text (one named constant per rejection, LOG-002) + `InvalidArgument`; `loadGameConfig(path)` (bounded 1 MiB read + the M0-CORE-07 parse + the schema) replaces the CLI's read/parse sequence (exit codes unchanged); `EngineConfigOverride` + `applyConfigOverride` (the FR-1.5 programmatic override-of-a-subset merge: per-leaf optionals, each set field validated in its documented domain, first set field that fails wins, value semantics, `assetRoots` replacement); `ConfigHotReloader` (move-only, no thread, caller-driven poll — debug builds ONLY, the replay/`record_disabled` pattern: release builds reject with `config/hot_reload_disabled`): baseline bytes + validated baseline, byte compare per poll, non-sim changes (camera.*, draw/particle budgets, asset_roots) apply in place + `config/hot_reload_applied` (Info, keys named) + baseline advance, sim-affecting changes (version, tick_rate_hz, entity_budget, churn_per_frame_budget, seed, determinism.*, budgets.system_time_default_ms) refused ATOMICALLY + `config/hot_reload_rejected` (Error, first key + old/new) with config/baseline untouched, read/parse/schema failures keep the previous config (`config/hot_reload_read_failed` or the loader's event), moved-from poll fails without logging (stopped-state precedent); the replay identity's `configHash` covers only the sim-affecting fields — the declared presentation values are deliberately excluded, so the encoding (tag 1) is UNCHANGED and every committed baseline/replay log stays valid (replay.h/.cpp comments updated); docs: NEW `docs/api/config.md` (key table, versioning + migration, rejection table, the override API, the hot-reload contract, Performance, misuse), engine.md config section rewritten to the final surface, replay.md/determinism.md/testing.md/docs-README/sim-README cross-refs updated; the provisional config surface in engine.h/engine.cpp folded into config.h/config.cpp (engine.h includes config.h; `Engine::create` re-validates the tick rate with the shared `kConfigTickRateInvalidMessage`); fixtures gain `"version": 1` (headless_smoke.json, samples/hello/config.json, the three replay smoke fixtures); NEW `game_config` CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path — `ConfigHotReload.*`); engine_tests drops the migrated `EngineConfigParse.*` (the suites live in game_config_tests.cpp); determinism_tests' config docs gain `"version": 1`; `laige-api.json` regenerated (734 symbols); local Verify: `ctest -R config` green (config_json + game_config + hello_config_valid), full `ctest` 88/88 on `build` (Debug g++), `build-asan` (leak-free), `build-release` (NDEBUG — the `hot_reload_disabled` path exercised), `build-clang`, `build-tsan` (config suites `halt_on_error=1`), `build-shared`; zero new warnings under NFR-8.10; untested: the MSVC `_fsopen` branch (CI-only, the M1-DET-02 precedent). Size: larger than the ~300-line guidance (the message table + hot reloader + 38-test suite) — noted here per the roadmap's split rule, not split | | 2026-09-23 | M1-PROF-02 | `0c7cf5c` / PR #49 | Frame graph / budget report (FR-11.2, PRD §9.1 S-6, §9.3 G-R5; M1-PROF-02 scope, nothing else): the `FrameBudgetRecorder` fixed 32-frame ring (`src/laige-sim/include/laige/sim/frame_budget.h` + `frame_budget.cpp` — `FrameBudgetRecord` per completed frame: the 0-based frame index, the tick delta, the frame's sim work ms, the pool-reservations sim-alloc delta, the G-R5 `budget_overrun`/`budget_critical` event deltas; `recordFrame` O(1) allocation-free hot path, `at(i)` oldest-first over the wrapping ring, `totalFrames()` keeps counting past the window — no silent truncation, CORE-008) and `buildFrameBudgetReport` (cold format pass, the AGENTS §12 field format): every DECLARED budget measured vs declared with a pass/flag — each system's declared `SystemDef::budgetMs` (fpx16_16, exact — ADR 0002) vs its M1-SYS-03 rolling window's **p99** (the G-R5 sustained-overrun signal; single recovered overruns stay visible in the per-frame records + the `warns`/`errors` counters), `sim_tick_avg`/`sim_tick_p99` (budgets.json, M0-CORE-08) vs the `Profiler`'s tick window (mean/p99), `sim_heap_allocs` (the PRD §8.1 hard-zero budget) vs the per-frame sim-alloc deltas (max); the M0-CORE-08 `budgetCheck` blocks embedded verbatim; a missing entry is a loud `NO_ENTRY`, an empty window a loud `NO_SAMPLES` (a zero-tick run is never silent), the over-budget systems list ascending id, and `overall=PASS|FAIL` (a broken harness folds to FAIL — CORE-008); the ENGINE wiring (always-on per-frame accumulation — two O(1) reads + two O(systemCount) G-R5 counter passes + one O(1) ring write per frame, no allocation, no logging, PERF-003/LOG-003; the run-setup allocation count stays exactly three — `HeadlessFramePathAllocatesNothing` still pins it — the ring is created in `Engine::create`): `startBudgetReport(budgetsPath, lastNFrames)` (EVERY build — diagnostics, not replay state; loads the table now, builds + CACHES the report at the run's end on EVERY path — readable after the shutdown, the `profileStats()` precedent; a budget FAIL never fails the run — CORE-002; errors: stopped/empty-path/double-start `InvalidArgument`, load `IoError`/`MalformedInput` + `budget/report_*` structured events), `budgetReportRequested()`, `lastBudgetReport()`; the CLI surface (`tools/run/laige-run.cpp`): `--budget-report [N]` (1..32; omitted = all retained; printed to stdout after the profile summary line, even on a failed run), `--budgets ` (flag → `LAIGE_BUDGETS_PATH` env → `budgets.json` CWD — the laige-bench resolution order), `--fail-on-budget` (**exit 3** when the run COMPLETED but `overall=FAIL` — the PRD §8.1 CI gate; run failure stays exit 1, start/load failure exit 2); 14-test `budget_report` CTest entry (`tests/laige-sim/budget_report_tests.cpp`: the ring's newest-frames-oldest-first semantics, the SYNTHETIC OVER-BUDGET SYSTEM with correct numbers — the 2 ms burn vs a 0.1 ms fpx16_16 budget (20× — both G-R5 multipliers exceeded on the nominal floor, so the exact `runs=10 warns=10 errors=10` and p99 ≥ 2 assertions are preemption-tolerant): the declared budget echoed, the `over_budget:` line, `overall=FAIL`, the per-frame FAIL invariants — the engine's cached-after-shutdown report, the G-R5 events folded into the per-frame records (rate-limiting-off capture sink: 10 warns + 10 criticals exact), the healthy-world PASS, the loud NO_ENTRY/NO_SAMPLES, the start validation (double/empty/stopped/unreadable/malformed), and the record path's zero-allocation (`budget-report-recorder-zeroalloc frames=1000 allocs=0`, non-sanitizer trees)); `laige_run_budget` CLI smoke (every P0 OS job: 30-tick run, `--budget-report 4 --budgets /budgets.json --fail-on-budget`, passes iff `overall=PASS` + exit 0); sample report committed as `tests/laige-sim/fixtures/budget_report_sample.txt` (a sample, NOT a golden — the report carries wall-clock values); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostics — never enter sim state, hashes, or replays, ARCH-009; `tools/laige-determinism-lint` green); `laige-api.json` regenerated (24 headers; +`FrameBudgetRecord`/`kFrameBudgetWindow`/`FrameBudgetRecorder`/`FrameBudgetReportOptions`/`FrameBudgetReport`/`buildFrameBudgetReport` + the three `Engine` methods + `Profiler::tickWindow`); docs in the same change: `docs/api/frame_budget.md` (new — the declared budgets, the report format + pass/flag semantics, the Engine surface, the CLI + exit 3, Performance, determinism, Testing/CI) + engine.md (CLI flags, exit 3, per-frame cost, misuse, Testing) + profiler.md cross-ref + the docs/README + sim README indexes; local Verify: canonical g++ Debug tree zero-warning, `ctest -R budget_report` green (14/14), `ctest -R laige_run_budget` green, `ctest -R profiler`/`engine`/`system_timing` green (the zero-allocation pin unchanged), both lints OK | | 2026-09-21 | M1-PROF-01 | `a811297` / PR #47 | Always-on profiler counters (FR-11.1, DBG-008; M1-PROF-01 scope, nothing else): the `Profiler` core (`src/laige-sim/include/laige/sim/profiler.h` + `profiler.cpp`) — always-on, fixed-storage, no allocation after init: tick/frame time rolling windows (512/256 samples, M0-CORE-08 `Histogram`; record is O(1) allocation-free, drops the OLDEST, the since-construction counters keep counting), draw calls / texture binds / net bytes counters (0 in headless M1 — the fields exist per FR-11.1, the render/network subsystems feed them in M2/M3), and the cold `snapshot()` (own counters; `snapshot(world)` adds the world-pulled fields — entity total/alive/capacity, sim alloc count = `World::archetypeStats().totalReservations` (the M1-ECS-03 pool accounting, target 0), system count — read COLD, never copied; per-system windows stay in `World`, pulled only in the report); move-only (moved-from = stopped: records no-op, empty snapshot); `setEnabled`/`enabled` (disabled = one branch, no recording); report surface: `formatProfileSummaryLine` (the CLI one-liner), `formatProfileText` / `formatProfileJson` (version-1 schema: counters, tick/frame windows (n==0 → `n=0` text / JSON `null`, never NaN), world fields, per-system entries with the M1-SYS-03 window stats), `writeProfile` (truncating write, no partial file on failure, `Result` — `IoError`); wiring: `GameLoop::Options::profiler` (non-owning) — `runOneTick` times the tick body (the frame's `beginFrame` + one `runSystems` dispatch) with the M0-CORE-08 `TimeIt` and records on SUCCESS only (a failed tick is neither counted nor recorded), null/disabled = one branch; the engine owns the profiler (`Engine::create` constructs it — engine setup, not run setup, so the "exactly three one-shot allocations per run" claim stays true; the run loop adds the frame feed — two clock reads + one ring write per frame, excluding the pacing sleep, first frame not recorded, failed frames not recorded; `shutdown()` releases it in the pools step, abandoning a started-but-unfinalized report with `profiler/report_aborted`); the per-run report: `startProfileReport(path)` (EVERY build — diagnostics, not replay state, no `NDEBUG` gate), written at the END of the run on EVERY path (a zero-tick run writes a zero-tick report, CORE-008), a write failure does NOT fail the run (sticky `profileReportStatus()`, `profiler/report_write_failed` Error), `profileStats()` = the last run's cached snapshot (the world is released in shutdown); `laige-run --prof-out ` (start failure exits 2; a write failure leaves the run `status=ok` and exits 2) + the always-printed `laige-run profile: …` one-line summary (the byte-stable `status=ok` line untouched — the summary is a separate stdout line); structured events (subsystem `profiler`, NFR-13.3 5-field grammar): `report_started` (Info), `report_written` (Info), `report_write_failed` (Error), `report_aborted` / `report_already_started` / `report_path_invalid` (Warn); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostic — never enters sim state, hashes, or replays, ARCH-009); 26-test `profiler` CTest entry (`tests/laige-sim/profiler_tests.cpp`: exact percentiles 1..100 → p50=50/p95=95/p99=99/mean=50.5, rollover cap, independent windows, zero-capacity drop, adders, disabled no-op + preserved state, moved-from stop, cold snapshot, per-completed-tick timing (failed tick unrecorded), the engine's per-run cache + report (written on every run path, version-1 JSON parseable, double-start/empty-path/stopped-engine rejections, write failure sticky without failing the run, pre-run shutdown abandonment with no file on disk), the greppable text form, the record path's zero-allocation (`profiler-zeroalloc ticks=1000 allocs=0`, non-sanitizer trees), and the enabled-cost gate ON vs OFF over 10k-entity ticks ≤ 1% (`profiler-cost on_p50=0.557288 off_p50=0.555746 overhead_pct=0.277465`, best-of-2 per arm, non-sanitizer trees — the LAIGE_ALLOC_COUNTER gate: sanitizer instrumentation inflates the fixed per-tick cost, 1.46% on the ASan tree)); docs in the same change: `docs/api/profiler.md` (new) + engine.md / game_loop.md updates + the docs/README, sim README, debugging README, tools README indexes; baseline `docs/benchmarks/baselines/m1-profiler-cost.md` (full AGENTS §12 metadata, verbatim runs — measured +0.28% vs the 1% gate); local Verify: canonical g++ tree zero-warning, full `ctest` 89/89 (the new `profiler` entry + `api-real-tree` + `determinism-lint-real-tree` green after the manifest regeneration), `laige-run --prof-out` smoke (the summary line + the valid JSON report on disk); cross-tree builds + `tools/laige-include-lint` in the PR branch | -| 2026-09-24 | M1-ALLOC-01 | `4564029` / PR #50 | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `__return_address` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK | +| 2026-09-24 | M1-ALLOC-01 | `4564029` / PR #50 | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `__return_address` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK; CI (observed via the GitHub API): the PR ci-pull.yml run 35988075605 on c9587fa green — all 8 jobs passed (linux-gcc/clang/asan+UBSan/tsan each ctest 92/92, the determinism check + source scan, the include-graph lint + dependency count, the public API manifest drift); macOS/Windows jobs skipped (label-gated, default-Linux P0 selection) | ---