diff --git a/docs/README.md b/docs/README.md index e762aa4..aa36271 100644 --- a/docs/README.md +++ b/docs/README.md @@ -120,6 +120,13 @@ still to land. snapshot + text/JSON reports, the `GameLoop` per-tick timing hook, the engine's per-run report (`laige-run --prof-out`), and the measured ≤1% enabled cost (M1-PROF-01; `laige-sim`). +- [The frame graph / budget report](api/frame_budget.md) — the + FR-11.2 per-frame budget report: every declared budget (system + time, total tick time, allocation count) measured vs declared with + a pass/flag, the over-budget systems list, the fixed + `FrameBudgetRecorder` ring, the engine's opt-in cached per-run + report (`laige-run --budget-report`, `--fail-on-budget`), and the + zero-allocation record path (M1-PROF-02; `laige-sim`). - [Headless engine run](api/engine.md) — `laige::Engine` (config → world → systems → loop): `run_headless(maxTicks)` the bounded + server run forms, the ordered idempotent CONC-006 diff --git a/docs/api/engine.md b/docs/api/engine.md index 8507a2b..8760fc7 100644 --- a/docs/api/engine.md +++ b/docs/api/engine.md @@ -290,7 +290,8 @@ std::uint64_t Engine::replayBytesWritten() const noexcept; ``` laige-run --headless CONFIG.json [--ticks N] [--replay LOG] - [--prof-out REPORT] + [--prof-out REPORT] [--budget-report [N]] + [--budgets PATH] [--fail-on-budget] ``` - `--headless CONFIG` — required: the JSON config file (bounded read, @@ -317,12 +318,34 @@ laige-run --headless CONFIG.json [--ticks N] [--replay LOG] happen); a write failure at run end does **not** fail the run — the run completes (exit `0`), the report error is printed on stderr, and the exit code becomes `2`. +- `--budget-report [N]` — **prints the last N frames' budget + report** (M1-PROF-02, FR-11.2 — + [api/frame_budget.md](frame_budget.md)) to stdout at the end of + the run, in the AGENTS §12 field format (every declared budget + measured vs declared with a pass/flag, plus the over-budget + systems list). `N`: 1..`kFrameBudgetWindow` (32); omitted = all + retained frames. **Every build** (diagnostics, not replay state). + The report needs a `budgets.json` (see `--budgets`); a load + failure exits `2` (the run did not happen). The report is printed + **even on a failed run** (the run failure dominates the exit + code). +- `--budgets PATH` — the `budgets.json` file (schema v1 — + [api/budget_harness.md](budget_harness.md)). Resolution order: + this argument, then the `LAIGE_BUDGETS_PATH` env var, then + `budgets.json` in the working directory (the `laige-bench` + resolution order). +- `--fail-on-budget` — **exit `3`** when the run **completed** but + the budget report is `overall=FAIL` (the CI gate — PRD §8.1 + budget policy). A failed run exits `1` regardless (the gate never + masks a run failure). - `--help` / `-h` — usage, exit 0. **Exit codes:** `0` = the run completed (and the profile report, if requested, was written); `1` = the engine run failed (the `Status`'s -error name is printed on stderr); `2` = usage, file, config, or -profile-report-write error. On completion the run prints one +error name is printed on stderr); `2` = usage, file, config, +profile-report-write, or budget-report-start error; **`3` = budget +failure** (`--fail-on-budget`: the run completed, the report is +`overall=FAIL`). On completion the run prints one machine-greppable summary line on stdout — **byte-stable, CI greps it** (`laige_run_smoke`'s `PASS_REGULAR_EXPRESSION "status=ok"`): @@ -338,6 +361,20 @@ per-run snapshot): laige-run profile: ticks=1000 frames=1001 tick_ms: n=1000 min=... frame_ms: n=1001 ... entities_alive=... sim_allocs=... draw_calls=0 texture_binds=0 net_bytes=0 ``` +and, when `--budget-report` is given, the budget report itself +(M1-PROF-02 — the machine-greppable field format; a sample is +committed at `tests/laige-sim/fixtures/budget_report_sample.txt`): + +``` +laige-budget-report version=1 +laige-budget-report context: workload=headless build= machine= warmup=0 +laige-budget-report frames: n=4 total=31 +laige-budget-report frame=27 ticks=1 tick_after=27 frame_ms=... sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +... +laige-budget-report over_budget: none +laige-budget-report overall=PASS +``` + The CLI then calls `engine.shutdown()` a second time — the double-shutdown idempotency the step verifies — and exits. @@ -370,6 +407,15 @@ double-shutdown idempotency the step verifies — and exits. fixed window storages) happens in `Engine::create`, not in the run's setup path — the "exactly three one-shot allocations per run" claim above stays true. +- **Frame graph / budget report (M1-PROF-02):** the per-frame + accumulation is **always on** — two O(1) reads (the loop's tick + count, the pool reservations), two O(systemCount) passes over the + per-system G-R5 counters, and one O(1) ring write per completed + frame. No allocation, no logging (PERF-003, LOG-003). The ring is + fixed storage created in `Engine::create` (the engine's setup, not + the run's) — the "exactly three one-shot allocations per run" claim + stays true. The report build + cache at the run's end is cold + (one format pass, once per run — [api/frame_budget.md](frame_budget.md)). - **Complexity** — `run_headless` is O(maxTicks × per-tick system work), bounded per frame by `frameBudgetTicks`. The drop path is cold: one rate-limited warn per overload frame (M1-LOOP-01). @@ -408,6 +454,14 @@ double-shutdown idempotency the step verifies — and exits. NOT fail the run — it is sticky in `profileReportStatus()` (the `laige-run` CLI maps it to exit 2). See the The profiler section above and [api/profiler.md](profiler.md). +- **Start the budget report once, after registration, before the + run** — a second `startBudgetReport` fails `InvalidArgument` + (`budget/report_already_started`); a `budgets.json` load failure + fails the START (the run did not happen — the `laige-run` CLI maps + it to exit 2). A budget FAIL at the end of the run does NOT fail + the run — `lastBudgetReport().passed` is the gate (the CLI's + `--fail-on-budget` maps `overall=FAIL` to exit 3). See + [api/frame_budget.md](frame_budget.md). ## Testing and CI @@ -421,6 +475,19 @@ double-shutdown idempotency the step verifies — and exits. 10 000 slots): must exit 0 and print `status=ok` on every P0 OS job; TIMEOUT 300 s (≈16.7 s nominal); the TSan job sets `TSAN_OPTIONS=halt_on_error=1`. +- `ctest -R laige_run_budget` — the M1-PROF-02 CLI smoke: + `laige-run --headless tests/laige-sim/fixtures/headless_smoke.json + --ticks 30 --budget-report 4 --budgets /budgets.json + --fail-on-budget`; passes iff the report prints `overall=PASS` and + the run exits 0 (a FAIL report exits 3, which ctest fails). The + budgets path is passed explicitly (the ctest CWD is the build + tree — the working-directory fallback would not resolve there). + See [api/frame_budget.md](frame_budget.md). +- `ctest -R budget_report` — the M1-PROF-02 unit suite (the + recorder ring, the synthetic over-budget system's correct numbers, + the NO_ENTRY/NO_SAMPLES semantics, the cached per-run report, the + record-path zero-allocation). See + [api/frame_budget.md](frame_budget.md). - `ctest -R replay_record` — the M1-DET-02 replay suite (the format round trip, the malformed-input table, the recorder contract, the identity hashes, the engine's per-tick recording + failure stop); diff --git a/docs/api/frame_budget.md b/docs/api/frame_budget.md new file mode 100644 index 0000000..517319d --- /dev/null +++ b/docs/api/frame_budget.md @@ -0,0 +1,264 @@ +# The frame graph / budget report (`FrameBudgetRecorder`, M1-PROF-02) + +The per-frame budget report (M1-PROF-02; PRD FR-11.2, §9.1 S-6, §9.3 +G-R5; AGENTS CORE-001, CORE-008): for each **declared budget** — +each system's declared time budget, the total tick-time budgets +(`sim_tick_avg` / `sim_tick_p99`), and the sim allocation-count +budget (`sim_heap_allocs`) — the report lists the **measured value +vs the declared value with a pass/flag**, plus the **over-budget +systems list** (the G-R5 event feed, FR-11.2). This step ships the +report core (`buildFrameBudgetReport`), the fixed per-frame +`FrameBudgetRecorder` ring, the `Engine` opt-in wiring (per-frame +accumulation + the cached per-run report), and the `laige-run +--budget-report` CLI flag with its `--fail-on-budget` CI gate. + +Public header: +`src/laige-sim/include/laige/sim/frame_budget.h` +(`FrameBudgetRecord`, `kFrameBudgetWindow`, `FrameBudgetRecorder`, +`FrameBudgetReportOptions`, `FrameBudgetReport`, +`buildFrameBudgetReport`, the full contract); implementation: +`src/laige-sim/frame_budget.cpp`. Wiring: `src/laige-sim/engine.cpp` +(recorder ownership, the per-frame record in `runFrames`, the +report build at the run's end, `startBudgetReport`). CLI: +`tools/run/laige-run.cpp` (`--budget-report`, `--budgets`, +`--fail-on-budget`). Unit suite: `ctest -R budget_report` +(`tests/laige-sim/budget_report_tests.cpp`) plus the CLI smoke +`ctest -R laige_run_budget` (`tools/run/CMakeLists.txt`). + +```cpp +// The engine owns the recorder (fixed storage — created in +// Engine::create, no allocation). Opt in to the per-run report +// (EVERY build — diagnostics, not replay state), after all +// registration, before the run: +const laige::Status r = + engine.startBudgetReport("budgets.json"); // lastNFrames omitted: all retained +engine.run_headless(10'000); +// The report is BUILT and CACHED at the end of the run (on EVERY +// path — a failed run's report describes what happened), readable +// after the shutdown (the world and the profiler are released by +// then — the profileStats() cache precedent): +const laige::FrameBudgetReport& rep = engine.lastBudgetReport(); +if (!rep.passed) { /* the caller gates — laige-run --fail-on-budget + maps overall=FAIL to exit 3 */ } +``` + +## The declared budgets (what is evaluated) + +| Declared budget | Source | Measured over | +|---|---|---| +| **System time** — one per registered system | `SystemDef::budgetMs` (M1-SYS-01; fpx16_16 ms — exact, ADR 0002). M1 systems always declare one; the config's `system_time_default_ms` is the registration default and is not separately evaluated here | the system's M1-SYS-03 rolling window (`World::systemTimingWindow(id)`), **p99** — the same statistic the G-R5 `budget_overrun` warn carries | +| **Total tick time** — `sim_tick_avg` | `budgets.json` (M0-CORE-08 table; PRD §8.1 target 3 ms) | the `Profiler`'s tick window (`Profiler::tickWindow()`), **mean** | +| **Total tick time** — `sim_tick_p99` | `budgets.json` (PRD §8.1 target 5 ms) | the same tick window, **p99** | +| **Allocation count** — `sim_heap_allocs` | `budgets.json` (PRD §8.1 hard-zero budget, target 0) | the per-frame `FrameBudgetRecord::simAllocs` deltas of the retained frames (a cold local histogram), **max** | + +Every budget is an **at-most** upper bound (the M0-CORE-08 +convention; `target: 0` is a hard zero budget, not "unset"). The +per-frame line's `frame_ms` is **informational** — M1 declares no +per-frame tick budget (the tick budgets are rolling statistics over +the tick window). + +## The per-frame record (`FrameBudgetRecord`) + +One value per completed frame, accumulated on the hot path +(never on a failed frame — the frame did not complete): + +| Field | Meaning | +|---|---| +| `frame` | 0-based index within the run's run frames (the start-reference first frame is not one — the M1-PROF-01 run loop contract) | +| `tickAfter` | completed tick count after the frame (a bounded run ends with `tickAfter == maxTicks`) | +| `ticks` | ticks completed within the frame (0 when the frame ran ahead of the tick rate; several in a catch-up frame) | +| `frameMs` | the frame's sim work + presentation refresh in ms (0.0 when the profiler is disabled) | +| `simAllocs` | the frame's sim allocation count (the `World::archetypeStats().totalReservations` delta; 0 = steady state — the `sim_heap_allocs` budget's sample) | +| `overrunWarns` | the G-R5 `system/budget_overrun` WARN events issued during the frame (the per-frame delta of the per-system warn counters) | +| `criticalErrors` | the G-R5 `system/budget_critical` ERROR events issued during the frame (as above) | + +A frame **FAILs** iff `simAllocs > 0` or either G-R5 counter is +non-zero (that frame ran a system over budget, or the sim +allocated). + +## The recorder (`FrameBudgetRecorder`) + +A fixed ring of `kFrameBudgetWindow` (32) records — no allocation +after construction (the engine's setup creates it as a fixed array +member). `recordFrame` is O(1); recording beyond the window drops +the **oldest** record, and `totalFrames()` keeps counting every +recorded frame (a truncated window is observable — CORE-008: no +silent truncation). `at(i)` reads the retained records oldest-first +(the ring wraps — the retained set is not a contiguous span); +`reset()` clears it. At 60 Hz the window spans ~0.53 s. + +## The report (AGENTS §12 field format) + +`buildFrameBudgetReport(recorder, profiler, world, budgets, +options)` is a **cold** format pass (it allocates — reporting is +never a hot path): the machine-greppable text, one `key=value` field +per token, plus `passed` (the overall pass/flag). Layout: + +``` +laige-budget-report version=1 +laige-budget-report context: workload=headless build= machine= warmup=0 +laige-budget-report frames: n=4 total=31 +laige-budget-report frame=27 ticks=1 tick_after=27 frame_ms=0.000781 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=28 ticks=1 tick_after=28 frame_ms=0.001202 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=29 ticks=1 tick_after=29 frame_ms=0.001303 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=30 ticks=1 tick_after=30 frame_ms=0.002435 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +budget=sim_tick_avg result=PASS metric=mean unit=ms + after=0.00105637 before=0 target=3 + stats: n=30 min=0.000421 mean=0.00105637 p50=0.000832 p95=0.002475 p99=0.002545 max=0.002545 + context: workload=headless build= machine= warmup=0 +budget=sim_tick_p99 result=PASS metric=p99 unit=ms + after=0.002545 before=0 target=5 + stats: n=30 min=0.000421 mean=0.00105637 p50=0.000832 p95=0.002475 p99=0.002545 max=0.002545 + context: workload=headless build= machine= warmup=0 +budget=sim_heap_allocs result=PASS metric=max unit=allocs_per_frame + after=0 before=0 target=0 + stats: n=4 min=0 mean=0 p50=0 p95=0 p99=0 max=0 + context: workload=headless build= machine= warmup=0 +laige-budget-report over_budget: none +laige-budget-report overall=PASS +``` + +The three `budget=...` blocks are the M0-CORE-08 `budgetCheck` +report, embedded verbatim (`docs/api/budget_harness.md`). The +per-system section (one line per registered system, ascending id) +and the over-budget list follow the same field format: + +``` +laige-budget-report system id=1 name=BRBurn budget_ms=0.100006 runs=10 last_ms=2.00036 measured_p99_ms=2.00036 result=FAIL warns=10 errors=10 window: n=10 min=2.00021 mean=2.00037 p50=2.00036 p95=2.00036 p99=2.00036 max=2.00038 +laige-budget-report over_budget: id=1 name=BRBurn p99_ms=2.00036 budget_ms=0.100006 +laige-budget-report overall=FAIL +``` + +(`budget_ms` is the fpx16_16 declared budget rendered as a double — +0.1 ms becomes `0.100006` at 16.16 resolution; the value is exact, +the rendering is %.6g.) A real 0-system run's sample report is +committed at +`tests/laige-sim/fixtures/budget_report_sample.txt` (a **sample, +not a golden** — the report carries wall-clock values, so no +byte-exact golden test; the `budget_report` suite asserts the +machine-greppable structure instead). + +## Pass/flag semantics + +- **Frame:** FAIL iff `simAllocs > 0` or a G-R5 event fired in the + frame (the record above). +- **System:** **FAIL** iff the rolling window's p99 > the declared + `SystemDef` budget (a sustained overrun — the G-R5 counters + `warns`/`errors` count every single overrun; the rolling p99 is + the sustained signal). **NO_SAMPLES** iff the window is empty + (the system never ran — loud, never silent; the `budgetCheck` + precedent). +- **Declared budget:** the M0-CORE-08 `budgetCheck` result + (PASS/FAIL; **NO_SAMPLES** for an empty window — e.g. a zero-tick + run; **NO_ENTRY** when the `budgets.json` entry is missing — a + configuration error, loud). +- **Overall:** `overall=PASS` iff every section passes — a NO_SAMPLE + state or a NO_ENTRY folds to FAIL (a broken harness is loud, not + green — CORE-008). `FrameBudgetReport::passed` mirrors it. + +A single **recovered** overrun (warn + next ticks fast) is visible +in the per-frame records (that frame FAILs) and the `warns`/`errors` +counters, without failing the system's rolling p99 — the at-most +budgets measure sustained behavior, while the per-frame records +keep the transient visible. + +## The Engine surface + +| Member | Contract | +|---|---| +| `startBudgetReport(budgetsPath, lastNFrames = kFrameBudgetWindow)` | Opt-in, **EVERY build** (diagnostics, not replay state — like `startProfileReport`). Called after all registration, before `run_headless`; one report per run. Loads the `budgets.json` table now (cold setup path) and builds + caches the report at the run's end. `lastNFrames` bounds the report's per-frame section (1..`kFrameBudgetWindow`; larger values clamp). Errors: stopped engine → `InvalidArgument` (no log — the stopped-state precedent); empty path → `InvalidArgument` + warn; double start → `InvalidArgument` + warn; load failure → `IoError`/`MalformedInput` + warn. | +| `budgetReportRequested()` | True when the report was started. O(1). | +| `lastBudgetReport()` | The last run's report (`passed` + `report`), **cached** — readable after the shutdown (the world and the profiler are released; the `profileStats()` cache precedent). Empty/`false` before the first run. O(1), no allocation. | + +The per-frame **accumulation is always on** (a disabled cost of two +O(1) reads, two O(systemCount) G-R5 counter passes, and one O(1) +ring write per frame — no allocation, no logging; PERF-003, +LOG-003). Only the **report** is opt-in. + +Structured events (LOG-001, subsystem `budget`, NFR-13.3 5-field +messages): `report_started` (Info), `report_path_invalid` (Warn), +`report_already_started` (Warn), `report_load_failed` (Error). The +G-R5 budget events themselves stay under the `system` subsystem +(M1-SYS-03); the report folds their per-frame deltas into the +records — it never re-issues them. + +A budget **FAIL never fails the run** (diagnostics never gate the +simulation — CORE-002's priority order): the caller gates +(`laige-run --fail-on-budget`, or `lastBudgetReport().passed`). + +## The CLI (`laige-run`) + +``` +laige-run --headless --budget-report [N] \ + [--budgets ] [--fail-on-budget] +``` + +- `--budget-report [N]` — print the last N frames' budget report to + stdout at the end of the run (after the profile summary line), in + the AGENTS §12 field format. N: 1..`kFrameBudgetWindow` (32); + omitted = all retained frames. The report is printed **even on a + failed run** (the run failure dominates the exit code). +- `--budgets ` — the `budgets.json` file (schema v1). + Resolution: this argument, then the `LAIGE_BUDGETS_PATH` env var, + then `budgets.json` in the working directory (the `laige-bench` + resolution order). +- `--fail-on-budget` — exit **3** when the run **completed** but the + report is `overall=FAIL` (the CI gate — PRD §8.1 budget policy). + A failed run exits 1 regardless (the gate never masks a run + failure). + +Exit codes: 0 = ok; 1 = engine run failure; 2 = usage / IO / config / +profile-report-write / **budget-report-start** error; **3 = budget +failure** (`--fail-on-budget`; the run completed, the report is +`overall=FAIL`). + +## Performance (PERF-001/003, LOG-003) + +- **Per frame (hot path):** two O(1) reads (the loop's tick count, + the pool reservations), two O(systemCount) passes over the + per-system G-R5 counters, one O(1) ring write — no allocation, no + logging. The engine's run-setup allocation count is unchanged at + exactly three one-shot objects (the M1-HEAD-01 zero-allocation + test still pins it: the ring is created in `Engine::create`, + before the measured window). +- **Record path (fixed storage):** `recordFrame` is O(1), no + allocation — verified by `budget_report`'s + `RecorderRecordPathAllocatesNothing` (the test-only operator-new + counter, non-sanitizer trees; the sanitizer trees prove it + leak-free). +- **Report build (cold, once per run):** one O(systemCount) pass + + one O(n log n) histogram per budget + one format pass; it + **allocates** — reporting is never a hot path (the + `profiler.cpp` / `budget_harness.cpp` precedent). Built at the + run's end, before the shutdown, on every run path. +- **Ring storage:** 32 × 48 B = 1.5 KiB fixed (negligible). + +## Determinism (ARCH-009) + +The report is **diagnostics only**: the measured times and +allocation counts never enter sim state, state hashes, or replays +(the `LAIGE-DETERM-EXCEPTION` G-R8 markers at each `double` use +record the boundary). The `budgets.json` table and the declared +`SystemDef` budgets are configuration inputs (deterministic); the +measured values are wall-clock facts about the run. + +## Testing & CI + +- `ctest -R budget_report` — the unit suite (14 tests): the + recorder ring semantics, the synthetic over-budget system's + correct numbers (declared budget echoed, measured p99 above it, + exact `runs`/`warns`/`errors`, the `over_budget:` line, + `overall=FAIL`), the healthy-world PASS, the loud NO_ENTRY / + NO_SAMPLES semantics, the engine's cached-after-shutdown report, + the G-R5 fold into the per-frame records, the start validation, + and the record-path zero-allocation. The machine-greppable + `budget-report-overbudget` / `budget-report-engine` / + `budget-report-recorder-zeroalloc` lines land in the ctest + output. +- `ctest -R laige_run_budget` — the CLI smoke (every P0 OS job): a + 30-tick run with `--budget-report 4 --budgets /budgets.json + --fail-on-budget`; passes iff the report prints `overall=PASS` and + the run exits 0. +- The `budget_report` suite is in the TSAN property list + (`tests/laige-sim/CMakeLists.txt`) and required green under ASan + (the leak-free property of the ring and the cached report). diff --git a/docs/api/profiler.md b/docs/api/profiler.md index 95d4bf7..f50ed21 100644 --- a/docs/api/profiler.md +++ b/docs/api/profiler.md @@ -292,6 +292,10 @@ compressed into one greppable line. non-instrumented P0 CI job enforces the bound). - `ctest -R laige_run_smoke` — the CLI smoke (the byte-stable `status=ok` line; the profile summary line follows it). +- `ctest -R budget_report` + `ctest -R laige_run_budget` — the + M1-PROF-02 frame graph / budget report (the profiler's tick window + is the `sim_tick_avg` / `sim_tick_p99` declared budgets' source — + [api/frame_budget.md](frame_budget.md)). - The TSan job runs the `profiler` entry with `TSAN_OPTIONS=halt_on_error=1`. - The disabled-cost baseline: diff --git a/laige-api.json b/laige-api.json index ecc99e4..6c6934a 100644 --- a/laige-api.json +++ b/laige-api.json @@ -18,6 +18,7 @@ "src/laige-sim/include/laige/sim/determinism.h", "src/laige-sim/include/laige/sim/engine.h", "src/laige-sim/include/laige/sim/entity.h", + "src/laige-sim/include/laige/sim/frame_budget.h", "src/laige-sim/include/laige/sim/game_loop.h", "src/laige-sim/include/laige/sim/presentation.h", "src/laige-sim/include/laige/sim/profiler.h", @@ -516,27 +517,30 @@ {"name": "laige::DeterminismConfig::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 186, "signature": "bool enabled{true}", "summary": "Deterministic mode on/off (S-7: deterministic by default). M1 semantics in the header preamble \"Determinism mode semantics\".", "budget": null, "experimental": false}, {"name": "laige::DeterminismConfig::math", "kind": "variable", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 188, "signature": "SimMathBackend math{SimMathBackend::FixedPoint16_16}", "summary": "The SimMath backend the deterministic run uses (ADR 0002).", "budget": null, "experimental": false}, {"name": "LAIGE_DETERMINISM_SAFE", "kind": "macro", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 299, "signature": "#define LAIGE_DETERMINISM_SAFE(Type, ...)", "summary": "Mark Type as a determinism-safe storage/component type (G-R8, S-7): declare that Type's members are exactly the listed member types (every member; order is irrelevant — the list is a set of types). The mark specializes the trait with the verified member list:", "budget": null, "experimental": false}, - {"name": "laige::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 464, "signature": "class Engine", "summary": "The headless engine (M1-HEAD-01): config -> world -> systems -> loop, then the ordered CONC-006 shutdown. See the header preamble for the lifecycle, the run contract, the shutdown order, the config surface, the determinism scope, and the misuse warnings.", "budget": null, "experimental": false}, - {"name": "laige::Engine::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 482, "signature": "[[nodiscard]] static Result create(const EngineConfig& config) noexcept", "summary": "Setup phase (the engine's only backing allocations happen in the World's create — the registry tables and, when capacity > 0, the per-slot tables): validate the typed config, create the World (entityCapacity, churnPerFrameBudget, seed, determinism mode), and register the built-in component matching the configured SimMath backend (Position2DFpx16 default, Position2DFp32 for float_pinned_32 — M1-DET-01; the engine's built-ins always come first — ARCH-010). O(1) beyond the World's setup allocations.", "budget": null, "experimental": false}, - {"name": "laige::Engine::world", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 488, "signature": "[[nodiscard]] World* world() noexcept", "summary": "The engine's world (the game setup phase: register components and systems here, BEFORE run_headless). nullptr after shutdown or on a moved-from engine (CPP-008 nullability; the stopped-state precedent). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::config", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 492, "signature": "[[nodiscard]] const EngineConfig& config() const noexcept", "summary": "The engine configuration echo (the validated values). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::run_headless", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 518, "signature": "[[nodiscard]] Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept", "summary": "Run the headless engine: compute the schedule, create the loop (with the presentation onTick hook) and the snapshot, drive frames until maxTicks ticks have completed (0 = the server form: run until the process ends), then shut down (always — even on a failed frame; CONC-006). One engine run per engine: a second call (after any outcome) fails with InvalidArgument without logging (the stopped-state precedent).", "budget": "O(maxTicks x per-tick system work), bounded per frame by frameBudgetTicks (PERF-002); setup allocates three one-shot objects (the GameLoop, the PresentationSnapshot, and the snapshot slot table); the frame path allocates nothing.", "experimental": false}, - {"name": "laige::Engine::shutdown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 526, "signature": "void shutdown() noexcept", "summary": "The ordered, IDEMPOTENT shutdown (the header preamble \"The ordered shutdown\": loop -> world clear -> storage release -> logging flush). Safe before a run, after a run, and after a failed run; the destructor calls it. O(world clear cost); no logging on the success path beyond the facade's own flush.", "budget": null, "experimental": false}, - {"name": "laige::Engine::isShutDown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 530, "signature": "[[nodiscard]] bool isShutDown() const noexcept", "summary": "True once shutdown() has completed (or on a moved-from engine). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 536, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, - {"name": "laige::Engine::startReplayRecording", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 571, "signature": "[[nodiscard]] Status startReplayRecording(std::string_view path, std::uint64_t maxBytes) noexcept", "summary": "Start the opt-in replay recording of the upcoming run (M1-DET-02; see the header preamble \"Replay recording\" and docs/api/replay.md for the full contract). DEBUG BUILDS ONLY: a release build rejects the call with InvalidArgument plus the structured replay/record_disabled warn (CORE-008: never silent).", "budget": "cold path: one file open + one 40-byte header write; no per-tick cost while the engine is not recording.", "experimental": false}, - {"name": "laige::Engine::replayRecordingActive", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 576, "signature": "[[nodiscard]] bool replayRecordingActive() const noexcept", "summary": "True while a replay recording is active (started and not yet finalized or abandoned). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::replayBytesWritten", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 580, "signature": "[[nodiscard]] std::uint64_t replayBytesWritten() const noexcept", "summary": "The bytes the active recording has written (header + frame bytes; 0 when not recording). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::profiler", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 587, "signature": "[[nodiscard]] const Profiler* profiler() const noexcept", "summary": "The engine's always-on profiler (M1-PROF-01; the counters are live while the engine runs). nullptr after shutdown or on a moved-from engine (the world() nullability precedent). Use profileStats() for the run's cached summary. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::profileStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 596, "signature": "[[nodiscard]] ProfilerStats profileStats() const noexcept", "summary": "The last run's profile snapshot (the profiler's counters plus the world-pulled fields — entities, sim allocs, system count — captured at the end of the run, BEFORE the shutdown releases the world; all zeros before the first run). This is the feed the laige-run CLI's one-line summary prints (FR-11.1 \"exposed in the CLI\") and the M1-PROF-02 frame graph will consume per frame. O(1), no allocation, no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::startProfileReport", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 622, "signature": "[[nodiscard]] Status startProfileReport(std::string_view path) noexcept", "summary": "Start the opt-in per-run profile report (M1-PROF-01, FR-11.1 file export; see the header preamble \"The profiler\" for the full contract). EVERY build (the report is diagnostics, not replay state — unlike startReplayRecording's debug-only gate).", "budget": "O(1); one string copy; no per-tick cost.", "experimental": false}, - {"name": "laige::Engine::profileReportStatus", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 627, "signature": "[[nodiscard]] Status profileReportStatus() const noexcept", "summary": "The sticky outcome of the last started report: ok when no report was started or the write succeeded; the write error (IoError) otherwise. O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::profileReportActive", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 632, "signature": "[[nodiscard]] bool profileReportActive() const noexcept", "summary": "True while a report was started and its lifecycle has not ended (between startProfileReport and the run's finalization, or a shutdown's abandonment). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 637, "signature": "Engine(Engine&& other) noexcept", "summary": "Move transfers the owned state; the source becomes a STOPPED engine (world() nullptr, run_headless fails, shutdown is a no-op — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, - {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 638, "signature": "Engine& operator=(Engine&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 639, "signature": "Engine(const Engine&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 640, "signature": "Engine& operator=(const Engine&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Engine::~Engine", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 644, "signature": "~Engine() noexcept", "summary": "The destructor shuts down (CONC-006: owned work is released even when the caller forgets shutdown()).", "budget": null, "experimental": false}, + {"name": "laige::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 524, "signature": "class Engine", "summary": "The headless engine (M1-HEAD-01): config -> world -> systems -> loop, then the ordered CONC-006 shutdown. See the header preamble for the lifecycle, the run contract, the shutdown order, the config surface, the determinism scope, and the misuse warnings.", "budget": null, "experimental": false}, + {"name": "laige::Engine::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 542, "signature": "[[nodiscard]] static Result create(const EngineConfig& config) noexcept", "summary": "Setup phase (the engine's only backing allocations happen in the World's create — the registry tables and, when capacity > 0, the per-slot tables): validate the typed config, create the World (entityCapacity, churnPerFrameBudget, seed, determinism mode), and register the built-in component matching the configured SimMath backend (Position2DFpx16 default, Position2DFp32 for float_pinned_32 — M1-DET-01; the engine's built-ins always come first — ARCH-010). O(1) beyond the World's setup allocations.", "budget": null, "experimental": false}, + {"name": "laige::Engine::world", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 548, "signature": "[[nodiscard]] World* world() noexcept", "summary": "The engine's world (the game setup phase: register components and systems here, BEFORE run_headless). nullptr after shutdown or on a moved-from engine (CPP-008 nullability; the stopped-state precedent). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::config", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 552, "signature": "[[nodiscard]] const EngineConfig& config() const noexcept", "summary": "The engine configuration echo (the validated values). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::run_headless", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 578, "signature": "[[nodiscard]] Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept", "summary": "Run the headless engine: compute the schedule, create the loop (with the presentation onTick hook) and the snapshot, drive frames until maxTicks ticks have completed (0 = the server form: run until the process ends), then shut down (always — even on a failed frame; CONC-006). One engine run per engine: a second call (after any outcome) fails with InvalidArgument without logging (the stopped-state precedent).", "budget": "O(maxTicks x per-tick system work), bounded per frame by frameBudgetTicks (PERF-002); setup allocates three one-shot objects (the GameLoop, the PresentationSnapshot, and the snapshot slot table); the frame path allocates nothing.", "experimental": false}, + {"name": "laige::Engine::shutdown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 586, "signature": "void shutdown() noexcept", "summary": "The ordered, IDEMPOTENT shutdown (the header preamble \"The ordered shutdown\": loop -> world clear -> storage release -> logging flush). Safe before a run, after a run, and after a failed run; the destructor calls it. O(world clear cost); no logging on the success path beyond the facade's own flush.", "budget": null, "experimental": false}, + {"name": "laige::Engine::isShutDown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 590, "signature": "[[nodiscard]] bool isShutDown() const noexcept", "summary": "True once shutdown() has completed (or on a moved-from engine). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 596, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, + {"name": "laige::Engine::startReplayRecording", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 631, "signature": "[[nodiscard]] Status startReplayRecording(std::string_view path, std::uint64_t maxBytes) noexcept", "summary": "Start the opt-in replay recording of the upcoming run (M1-DET-02; see the header preamble \"Replay recording\" and docs/api/replay.md for the full contract). DEBUG BUILDS ONLY: a release build rejects the call with InvalidArgument plus the structured replay/record_disabled warn (CORE-008: never silent).", "budget": "cold path: one file open + one 40-byte header write; no per-tick cost while the engine is not recording.", "experimental": false}, + {"name": "laige::Engine::replayRecordingActive", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 636, "signature": "[[nodiscard]] bool replayRecordingActive() const noexcept", "summary": "True while a replay recording is active (started and not yet finalized or abandoned). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::replayBytesWritten", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 640, "signature": "[[nodiscard]] std::uint64_t replayBytesWritten() const noexcept", "summary": "The bytes the active recording has written (header + frame bytes; 0 when not recording). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::profiler", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 647, "signature": "[[nodiscard]] const Profiler* profiler() const noexcept", "summary": "The engine's always-on profiler (M1-PROF-01; the counters are live while the engine runs). nullptr after shutdown or on a moved-from engine (the world() nullability precedent). Use profileStats() for the run's cached summary. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::profileStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 656, "signature": "[[nodiscard]] ProfilerStats profileStats() const noexcept", "summary": "The last run's profile snapshot (the profiler's counters plus the world-pulled fields — entities, sim allocs, system count — captured at the end of the run, BEFORE the shutdown releases the world; all zeros before the first run). This is the feed the laige-run CLI's one-line summary prints (FR-11.1 \"exposed in the CLI\") and the M1-PROF-02 frame graph will consume per frame. O(1), no allocation, no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::startProfileReport", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 682, "signature": "[[nodiscard]] Status startProfileReport(std::string_view path) noexcept", "summary": "Start the opt-in per-run profile report (M1-PROF-01, FR-11.1 file export; see the header preamble \"The profiler\" for the full contract). EVERY build (the report is diagnostics, not replay state — unlike startReplayRecording's debug-only gate).", "budget": "O(1); one string copy; no per-tick cost.", "experimental": false}, + {"name": "laige::Engine::profileReportStatus", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 687, "signature": "[[nodiscard]] Status profileReportStatus() const noexcept", "summary": "The sticky outcome of the last started report: ok when no report was started or the write succeeded; the write error (IoError) otherwise. O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::profileReportActive", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 692, "signature": "[[nodiscard]] bool profileReportActive() const noexcept", "summary": "True while a report was started and its lifecycle has not ended (between startProfileReport and the run's finalization, or a shutdown's abandonment). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::startBudgetReport", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 732, "signature": "[[nodiscard]] Status startBudgetReport( std::string_view budgetsPath, std::uint32_t lastNFrames = kFrameBudgetWindow) noexcept", "summary": "Start the opt-in per-run budget report (M1-PROF-02, FR-11.2 — the header preamble \"The frame graph / budget report\"; the report's format and pass/flag semantics in frame_budget.h, docs/api/frame_budget.md). EVERY build (diagnostics, not replay state — like startProfileReport).", "budget": "O(file read + parse) setup path; no per-tick cost.", "experimental": false}, + {"name": "laige::Engine::budgetReportRequested", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 738, "signature": "[[nodiscard]] bool budgetReportRequested() const noexcept", "summary": "True when the budget report was started (lastBudgetReport() is the meaningful read after a run). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::lastBudgetReport", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 747, "signature": "[[nodiscard]] const FrameBudgetReport& lastBudgetReport() const noexcept", "summary": "The last run's budget report (frame_budget.h): the overall pass/flag plus the machine-greppable text (AGENTS §12 field format). All-zero/empty before the first run (the profileStats() zero-state precedent); read it after a run, on every run path (a failed run's report describes what happened — loud NO_SAMPLES lines included). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 752, "signature": "Engine(Engine&& other) noexcept", "summary": "Move transfers the owned state; the source becomes a STOPPED engine (world() nullptr, run_headless fails, shutdown is a no-op — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 753, "signature": "Engine& operator=(Engine&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 754, "signature": "Engine(const Engine&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 755, "signature": "Engine& operator=(const Engine&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Engine::~Engine", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 759, "signature": "~Engine() noexcept", "summary": "The destructor shuts down (CONC-006: owned work is released even when the caller forgets shutdown()).", "budget": null, "experimental": false}, {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 275, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 276, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 277, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, @@ -610,6 +614,29 @@ {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 891, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 892, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 897, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord", "kind": "struct", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 169, "signature": "struct FrameBudgetRecord", "summary": "One completed frame's cheap budget scalars (M1-PROF-02). Fixed storage, since-construction units documented in the header preamble. A record is a value: built by the Engine's run loop and stored in the FrameBudgetRecorder ring (no pointer, no lifetime question — CPP-002).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::frame", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 173, "signature": "std::uint64_t frame{}", "summary": "The 0-based index of the frame within the run's run frames (the start-reference first frame is not one of them — the M1-PROF-01 run loop contract).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::tickAfter", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 176, "signature": "std::uint64_t tickAfter{}", "summary": "The completed tick count after this frame (the cumulative tick index; a bounded run ends with tickAfter == the run target).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 180, "signature": "std::uint32_t ticks{}", "summary": "The completed ticks WITHIN this frame (0 when the frame ran ahead of the tick rate; several in a catch-up frame — the M1-LOOP-01 accumulator).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::frameMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 183, "signature": "double frameMs{}", "summary": "The frame's sim work + presentation refresh in ms (0.0 when the profiler is disabled — no measurement — the preamble).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::simAllocs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 186, "signature": "std::uint64_t simAllocs{}", "summary": "0 = the steady-state target — the PRD §8.1 hard-zero budget).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::overrunWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 189, "signature": "std::uint32_t overrunWarns{}", "summary": "The G-R5 system/budget_overrun WARN events issued during this frame (the per-frame delta of the per-system warn counters).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecord::criticalErrors", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 192, "signature": "std::uint32_t criticalErrors{}", "summary": "The G-R5 system/budget_critical ERROR events issued during this frame (as above).", "budget": null, "experimental": false}, + {"name": "laige::kFrameBudgetWindow", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 202, "signature": "inline constexpr std::uint32_t kFrameBudgetWindow = 32", "summary": "The number of frames the FrameBudgetRecorder retains (CORE-005). At the default 60 Hz tick rate the window spans ~0.53 s — long enough for a frame graph to show a regression, small enough that the ring (32 × 48 B) is negligible fixed storage. 0 is not a capacity for the ring itself (a 0-frame report is the loud NO_SAMPLES state — the budgetCheck precedent); kFrameBudgetWindow is the default and the max for FrameBudgetReportOptions::lastNFrames.", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecorder", "kind": "class", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 207, "signature": "class FrameBudgetRecorder", "summary": "The fixed ring over the last frames' records (M1-PROF-02). The Engine owns one (setup path: no allocation — the ring is a fixed array member; the profiler's fixed-storage precedent).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecorder::FrameBudgetRecorder", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 211, "signature": "FrameBudgetRecorder() = default", "summary": "Construct the recorder (setup path): no allocation (the fixed ring), nothing recorded (count() 0, totalFrames() 0).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetRecorder::recordFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 220, "signature": "void recordFrame(FrameBudgetRecord record) noexcept", "summary": "Record one completed frame (hot path — the Engine's run loop). O(1): one ring write; no allocation, no logging (PERF-003, LOG-003). Beyond kFrameBudgetWindow the OLDEST record is dropped (the M0-CORE-08 window semantics); totalFrames() keeps counting every recorded frame, so a truncated window is observable (CORE-008: silent truncation is not allowed).", "budget": "O(1); no allocation (hot path).", "experimental": false}, + {"name": "laige::FrameBudgetRecorder::reset", "kind": "method", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 225, "signature": "void reset() noexcept", "summary": "Clear the ring and the counters (idempotent). Cold path (the tests; a run never resets mid-flight).", "budget": "O(kFrameBudgetWindow) cold path; no allocation.", "experimental": false}, + {"name": "laige::FrameBudgetRecorder::totalFrames", "kind": "method", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 230, "signature": "[[nodiscard]] std::uint64_t totalFrames() const noexcept", "summary": "The frames recorded since construction / the last reset (not truncated — CORE-008).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::FrameBudgetRecorder::count", "kind": "method", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 234, "signature": "[[nodiscard]] std::uint32_t count() const noexcept", "summary": "The records currently retained (0..kFrameBudgetWindow).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::FrameBudgetRecorder::at", "kind": "method", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 245, "signature": "[[nodiscard]] const FrameBudgetRecord& at(std::uint32_t i) const noexcept", "summary": "The retained record at position `i` in OLDEST-FIRST order (0 = the oldest retained frame; `i` in 0..count() - 1). The last min(totalFrames(), kFrameBudgetWindow) recorded frames in time order (the M0-CORE-08 rolling-window order). Points into the recorder's fixed storage (valid until the next recordFrame / reset). The ring wraps, so the retained records are not a contiguous span — read them through this accessor (the cold report's loop, the tests).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::FrameBudgetReportOptions", "kind": "struct", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 264, "signature": "struct FrameBudgetReportOptions", "summary": "The report options (API-006).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetReportOptions::lastNFrames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 269, "signature": "std::uint32_t lastNFrames{kFrameBudgetWindow}", "summary": "The number of frames to include, oldest first (1.. kFrameBudgetWindow; 0 = kFrameBudgetWindow — all retained). Frames beyond the retained window are not recoverable (the ring dropped them — CORE-008).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetReportOptions::context", "kind": "variable", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 273, "signature": "BudgetReportContext context{}", "summary": "The AGENTS §12 caller context for the report (the workload / build / machine the numbers were measured on, the warmup sample count — the M0-CORE-08 BudgetReportContext).", "budget": null, "experimental": false}, + {"name": "laige::FrameBudgetReport", "kind": "struct", "header": "src/laige-sim/include/laige/sim/frame_budget.h", "line": 280, "signature": "struct FrameBudgetReport", "summary": "The evaluated report (M1-PROF-02): the overall pass/flag plus the formatted text (the header preamble's layout). A value: the Engine caches the last run's report (cold string — reporting is never a hot path).", "budget": null, "experimental": false}, + {"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}, @@ -663,50 +690,51 @@ {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 350, "signature": "PresentationSnapshot& operator=(PresentationSnapshot&& other) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 353, "signature": "PresentationSnapshot(const PresentationSnapshot&) = delete", "summary": "No copies (the unique backing table).", "budget": null, "experimental": false}, {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 354, "signature": "PresentationSnapshot& operator=(const PresentationSnapshot&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kProfilerTickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 114, "signature": "inline constexpr std::uint32_t kProfilerTickWindowSamples = 512", "summary": "The default tick-time rolling window capacity (CORE-005): 512 samples ≈ 8.5 s of tick history at the default 60 Hz — long enough for a stable p99, small enough that the O(n log n) stats pass stays cold. Overridable per profiler (Options); the M1-SYS-03 per-system windows keep their own fixed capacity (kSystemTimingWindowSamples).", "budget": null, "experimental": false}, - {"name": "laige::kProfilerFrameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 118, "signature": "inline constexpr std::uint32_t kProfilerFrameWindowSamples = 256", "summary": "The default frame-time rolling window capacity (CORE-005): 256 samples ≈ 4.3 s of frame history at the default 60 Hz.", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 126, "signature": "struct ProfilerStats", "summary": "One profiler snapshot (a plain value; the CLI one-line summary, the report writers, the engine's per-run cache, and the M1-PROF-02 frame graph consume it). The counters are since-construction and never truncate (the histogram windows may roll — their `n` fields say so); the HistogramStats fields are NaN when the corresponding window is empty (check n — the M0-CORE-08 contract).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 128, "signature": "std::uint64_t ticks{}", "summary": "Completed ticks recorded (== the tick-time samples recorded).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 130, "signature": "std::uint64_t frames{}", "summary": "Frames recorded (== the frame-time samples recorded).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::drawCalls", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 132, "signature": "std::uint64_t drawCalls{}", "summary": "Draw calls submitted (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::textureBinds", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 134, "signature": "std::uint64_t textureBinds{}", "summary": "Texture binds (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::netBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 136, "signature": "std::uint64_t netBytes{}", "summary": "Network bytes (0 in headless M1 — the M3 network feed).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::tickTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 138, "signature": "HistogramStats tickTimeMs{}", "summary": "The tick-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::frameTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 140, "signature": "HistogramStats frameTimeMs{}", "summary": "The frame-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::worldAvailable", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 143, "signature": "bool worldAvailable{}", "summary": "World-pulled fields: snapshot(world) sets worldAvailable to true and fills them; the no-arg snapshot() leaves them at zero / false.", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::entitiesAlive", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 145, "signature": "std::uint32_t entitiesAlive{}", "summary": "Live entities right now (World::stats().inUse).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::entitiesTotal", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 147, "signature": "std::uint32_t entitiesTotal{}", "summary": "Entities created since world construction (totalCreated).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 149, "signature": "std::uint32_t entityCapacity{}", "summary": "The declared scene budget (World::stats().capacity).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::simAllocs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 155, "signature": "std::uint64_t simAllocs{}", "summary": "The sim alloc count: the sum of the pool accounting since world construction (M1-ECS-03 World::archetypeStats().totalReservations — the pool-backed sim storage's reserved column blocks). Target 0 for the steady-state per-frame DELTA (FR-11.1; M1-ALLOC-01 asserts it per tick).", "budget": null, "experimental": false}, - {"name": "laige::ProfilerStats::systems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 157, "signature": "std::uint32_t systems{}", "summary": "Registered systems (World::systemCount()).", "budget": null, "experimental": false}, - {"name": "laige::Profiler", "kind": "class", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 163, "signature": "class Profiler", "summary": "The always-on profiler counters (M1-PROF-01). See the header preamble for the counter model, the hot-path cost, and the ownership contract.", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 169, "signature": "struct Options", "summary": "The typed profiler configuration (API-006): the rolling window capacities (0 is legal — every record is dropped and stats() is always empty, the M0-CORE-08 capacity-0 semantics) and the enabled flag.", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options::tickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 172, "signature": "std::uint32_t tickWindowSamples{kProfilerTickWindowSamples}", "summary": "The tick-time rolling window capacity (default kProfilerTickWindowSamples).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options::frameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 175, "signature": "std::uint32_t frameWindowSamples{kProfilerFrameWindowSamples}", "summary": "The frame-time rolling window capacity (default kProfilerFrameWindowSamples).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Options::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 178, "signature": "bool enabled{true}", "summary": "The always-on counters on/off (default on). Disabled: every record call is a no-op (one branch — DBG-004).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 184, "signature": "explicit Profiler(Options options)", "summary": "Construct the profiler (setup path): exactly two backing allocations (the two window storages). No failure mode — every configuration is representable (a zero capacity is legal).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 189, "signature": "Profiler(Profiler&& other) noexcept", "summary": "Move transfers the state; the source becomes a STOPPED profiler (records are no-ops, snapshots return empty values — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 190, "signature": "Profiler& operator=(Profiler&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 191, "signature": "Profiler(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 192, "signature": "Profiler& operator=(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::recordTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 198, "signature": "void recordTick(double ms) noexcept", "summary": "Record one completed tick's measured time (ms, from the caller's TimeIt). A no-op when disabled or stopped. The caller measures only the completed tick (the GameLoop runOneTick contract).", "budget": "O(1); no allocation (hot path).", "experimental": false}, - {"name": "laige::Profiler::recordFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 205, "signature": "void recordFrame(double ms) noexcept", "summary": "Record one frame's measured time (ms, from the caller's TimeIt): the frame's sim work plus the presentation refresh, excluding the pacing sleep (the header preamble). A no-op when disabled or stopped.", "budget": "O(1); no allocation (hot path).", "experimental": false}, - {"name": "laige::Profiler::addDrawCalls", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 212, "signature": "void addDrawCalls(std::uint64_t count) noexcept", "summary": "The M2/M3 feeds for the FR-11.1 render/network counters (always 0 in headless M1 — the fields exist now; the render and network subsystems arrive later and call these). A no-op when disabled or stopped.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::Profiler::addTextureBinds", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 213, "signature": "void addTextureBinds(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::addNetBytes", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 214, "signature": "void addNetBytes(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Profiler::tickTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 219, "signature": "[[nodiscard]] HistogramStats tickTime() const noexcept", "summary": "The tick-time stats over the stored window (the M0-CORE-08 stats() — cold path). NaN when the window is empty (check n).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, - {"name": "laige::Profiler::frameTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 223, "signature": "[[nodiscard]] HistogramStats frameTime() const noexcept", "summary": "The frame-time stats over the stored window (cold path, as above).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, - {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 228, "signature": "[[nodiscard]] ProfilerStats snapshot() const noexcept", "summary": "The snapshot of the profiler's own counters (no world access). Cold path (the two stats passes); no side effects.", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, - {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 235, "signature": "[[nodiscard]] ProfilerStats snapshot(const World& world) const noexcept", "summary": "The snapshot plus the world-pulled fields (the entity counts, the sim alloc count, the system count — pulled from World::stats / World::archetypeStats / World::systemCount; the header preamble). Cold path; no world mutation.", "budget": "O(n log n) cold path; no allocation, no world mutation.", "experimental": false}, - {"name": "laige::Profiler::enabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 239, "signature": "[[nodiscard]] bool enabled() const noexcept", "summary": "The enabled flag (the GameLoop and the Engine read it to decide whether to measure at all — the hot-path branch).", "budget": null, "experimental": false}, - {"name": "laige::Profiler::setEnabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 244, "signature": "void setEnabled(bool on) noexcept", "summary": "Toggle the always-on counters at runtime (the DBG-002 profile switch: on is the \"Always\" profile, off turns the counters off). No side effects: already-recorded state is preserved.", "budget": null, "experimental": false}, - {"name": "laige::ProfileFormat", "kind": "enum", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 263, "signature": "enum class ProfileFormat : std::uint8_t", "summary": "The report format (M1-PROF-01 file export): the human-readable greppable text form and the machine-readable JSON form.", "budget": null, "experimental": false}, - {"name": "laige::ProfileFormat::Text", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 264, "signature": "Text = 0", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::ProfileFormat::Json", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 265, "signature": "Json = 1", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::formatProfileSummaryLine", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 274, "signature": "[[nodiscard]] std::string formatProfileSummaryLine(const ProfilerStats& stats)", "summary": "The CLI one-line summary (FR-11.1 \"exposed in ... the CLI\"): the run's counters plus the two window stat lines and the world-pulled fields, in one machine-greppable line (docs/api/engine.md, the laige-run section). Cold path (the two stats passes); allocates (a report string — reporting is never a hot path).", "budget": "O(n log n) cold path; allocates.", "experimental": false}, - {"name": "laige::formatProfileText", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 282, "signature": "[[nodiscard]] std::string formatProfileText(const Profiler& profiler, const World& world)", "summary": "The full report in the greppable text form (one section per line — the counters, the two windows, the world fields, and one line per registered system with its M1-SYS-03 window stats). Cold path; allocates. `world` is required (the per-system and world-pulled sections) — a moved-from / released world is not a legal argument.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, - {"name": "laige::formatProfileJson", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 288, "signature": "[[nodiscard]] std::string formatProfileJson(const Profiler& profiler, const World& world)", "summary": "The full report in the JSON form (version 1; schema documented in docs/api/profiler.md). Cold path; allocates.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, - {"name": "laige::writeProfile", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 297, "signature": "[[nodiscard]] Result writeProfile( const Profiler& profiler, const World& world, std::string_view path, ProfileFormat format)", "summary": "Write the report to `path` (truncating; the file appears only when the write fully succeeds — no partial report on failure). Cold path (the format pass plus one file write). Every failure is a Result: unreadable/unwritable path -> IoError (CORE-008: never silent). Returns the bytes written on success.", "budget": "O(systemCount × n log n) cold path; allocates; one file write.", "experimental": false}, + {"name": "laige::kProfilerTickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 116, "signature": "inline constexpr std::uint32_t kProfilerTickWindowSamples = 512", "summary": "The default tick-time rolling window capacity (CORE-005): 512 samples ≈ 8.5 s of tick history at the default 60 Hz — long enough for a stable p99, small enough that the O(n log n) stats pass stays cold. Overridable per profiler (Options); the M1-SYS-03 per-system windows keep their own fixed capacity (kSystemTimingWindowSamples).", "budget": null, "experimental": false}, + {"name": "laige::kProfilerFrameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 120, "signature": "inline constexpr std::uint32_t kProfilerFrameWindowSamples = 256", "summary": "The default frame-time rolling window capacity (CORE-005): 256 samples ≈ 4.3 s of frame history at the default 60 Hz.", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 128, "signature": "struct ProfilerStats", "summary": "One profiler snapshot (a plain value; the CLI one-line summary, the report writers, the engine's per-run cache, and the M1-PROF-02 frame graph consume it). The counters are since-construction and never truncate (the histogram windows may roll — their `n` fields say so); the HistogramStats fields are NaN when the corresponding window is empty (check n — the M0-CORE-08 contract).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::ticks", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 130, "signature": "std::uint64_t ticks{}", "summary": "Completed ticks recorded (== the tick-time samples recorded).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::frames", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 132, "signature": "std::uint64_t frames{}", "summary": "Frames recorded (== the frame-time samples recorded).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::drawCalls", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 134, "signature": "std::uint64_t drawCalls{}", "summary": "Draw calls submitted (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::textureBinds", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 136, "signature": "std::uint64_t textureBinds{}", "summary": "Texture binds (0 in headless M1 — the M2 render feed).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::netBytes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 138, "signature": "std::uint64_t netBytes{}", "summary": "Network bytes (0 in headless M1 — the M3 network feed).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::tickTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 140, "signature": "HistogramStats tickTimeMs{}", "summary": "The tick-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::frameTimeMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 142, "signature": "HistogramStats frameTimeMs{}", "summary": "The frame-time window stats (ms; NaN when n == 0).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::worldAvailable", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 145, "signature": "bool worldAvailable{}", "summary": "World-pulled fields: snapshot(world) sets worldAvailable to true and fills them; the no-arg snapshot() leaves them at zero / false.", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::entitiesAlive", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 147, "signature": "std::uint32_t entitiesAlive{}", "summary": "Live entities right now (World::stats().inUse).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::entitiesTotal", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 149, "signature": "std::uint32_t entitiesTotal{}", "summary": "Entities created since world construction (totalCreated).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 151, "signature": "std::uint32_t entityCapacity{}", "summary": "The declared scene budget (World::stats().capacity).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::simAllocs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 157, "signature": "std::uint64_t simAllocs{}", "summary": "The sim alloc count: the sum of the pool accounting since world construction (M1-ECS-03 World::archetypeStats().totalReservations — the pool-backed sim storage's reserved column blocks). Target 0 for the steady-state per-frame DELTA (FR-11.1; M1-ALLOC-01 asserts it per tick).", "budget": null, "experimental": false}, + {"name": "laige::ProfilerStats::systems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 159, "signature": "std::uint32_t systems{}", "summary": "Registered systems (World::systemCount()).", "budget": null, "experimental": false}, + {"name": "laige::Profiler", "kind": "class", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 165, "signature": "class Profiler", "summary": "The always-on profiler counters (M1-PROF-01). See the header preamble for the counter model, the hot-path cost, and the ownership contract.", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 171, "signature": "struct Options", "summary": "The typed profiler configuration (API-006): the rolling window capacities (0 is legal — every record is dropped and stats() is always empty, the M0-CORE-08 capacity-0 semantics) and the enabled flag.", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options::tickWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 174, "signature": "std::uint32_t tickWindowSamples{kProfilerTickWindowSamples}", "summary": "The tick-time rolling window capacity (default kProfilerTickWindowSamples).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options::frameWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 177, "signature": "std::uint32_t frameWindowSamples{kProfilerFrameWindowSamples}", "summary": "The frame-time rolling window capacity (default kProfilerFrameWindowSamples).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Options::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 180, "signature": "bool enabled{true}", "summary": "The always-on counters on/off (default on). Disabled: every record call is a no-op (one branch — DBG-004).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 186, "signature": "explicit Profiler(Options options)", "summary": "Construct the profiler (setup path): exactly two backing allocations (the two window storages). No failure mode — every configuration is representable (a zero capacity is legal).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 191, "signature": "Profiler(Profiler&& other) noexcept", "summary": "Move transfers the state; the source becomes a STOPPED profiler (records are no-ops, snapshots return empty values — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 192, "signature": "Profiler& operator=(Profiler&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::Profiler", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 193, "signature": "Profiler(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 194, "signature": "Profiler& operator=(const Profiler&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::recordTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 200, "signature": "void recordTick(double ms) noexcept", "summary": "Record one completed tick's measured time (ms, from the caller's TimeIt). A no-op when disabled or stopped. The caller measures only the completed tick (the GameLoop runOneTick contract).", "budget": "O(1); no allocation (hot path).", "experimental": false}, + {"name": "laige::Profiler::recordFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 207, "signature": "void recordFrame(double ms) noexcept", "summary": "Record one frame's measured time (ms, from the caller's TimeIt): the frame's sim work plus the presentation refresh, excluding the pacing sleep (the header preamble). A no-op when disabled or stopped.", "budget": "O(1); no allocation (hot path).", "experimental": false}, + {"name": "laige::Profiler::addDrawCalls", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 214, "signature": "void addDrawCalls(std::uint64_t count) noexcept", "summary": "The M2/M3 feeds for the FR-11.1 render/network counters (always 0 in headless M1 — the fields exist now; the render and network subsystems arrive later and call these). A no-op when disabled or stopped.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::Profiler::addTextureBinds", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 215, "signature": "void addTextureBinds(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::addNetBytes", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 216, "signature": "void addNetBytes(std::uint64_t count) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Profiler::tickTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 221, "signature": "[[nodiscard]] HistogramStats tickTime() const noexcept", "summary": "The tick-time stats over the stored window (the M0-CORE-08 stats() — cold path). NaN when the window is empty (check n).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, + {"name": "laige::Profiler::frameTime", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 225, "signature": "[[nodiscard]] HistogramStats frameTime() const noexcept", "summary": "The frame-time stats over the stored window (cold path, as above).", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, + {"name": "laige::Profiler::tickWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 234, "signature": "[[nodiscard]] const Histogram& tickWindow() const noexcept", "summary": "The tick-time window itself (the M1-PROF-02 frame graph's budget checks read it cold through the M0-CORE-08 budgetCheck — the header preamble's \"the per-frame budget report over them is M1-PROF-02\"). Non-owning const view into the profiler's fixed window (valid until the profiler is destroyed or moved — the profiler is owned by the Engine for its whole lifetime).", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 239, "signature": "[[nodiscard]] ProfilerStats snapshot() const noexcept", "summary": "The snapshot of the profiler's own counters (no world access). Cold path (the two stats passes); no side effects.", "budget": "O(n log n) cold path; no allocation.", "experimental": false}, + {"name": "laige::Profiler::snapshot", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 246, "signature": "[[nodiscard]] ProfilerStats snapshot(const World& world) const noexcept", "summary": "The snapshot plus the world-pulled fields (the entity counts, the sim alloc count, the system count — pulled from World::stats / World::archetypeStats / World::systemCount; the header preamble). Cold path; no world mutation.", "budget": "O(n log n) cold path; no allocation, no world mutation.", "experimental": false}, + {"name": "laige::Profiler::enabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 250, "signature": "[[nodiscard]] bool enabled() const noexcept", "summary": "The enabled flag (the GameLoop and the Engine read it to decide whether to measure at all — the hot-path branch).", "budget": null, "experimental": false}, + {"name": "laige::Profiler::setEnabled", "kind": "method", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 255, "signature": "void setEnabled(bool on) noexcept", "summary": "Toggle the always-on counters at runtime (the DBG-002 profile switch: on is the \"Always\" profile, off turns the counters off). No side effects: already-recorded state is preserved.", "budget": null, "experimental": false}, + {"name": "laige::ProfileFormat", "kind": "enum", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 274, "signature": "enum class ProfileFormat : std::uint8_t", "summary": "The report format (M1-PROF-01 file export): the human-readable greppable text form and the machine-readable JSON form.", "budget": null, "experimental": false}, + {"name": "laige::ProfileFormat::Text", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 275, "signature": "Text = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ProfileFormat::Json", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 276, "signature": "Json = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::formatProfileSummaryLine", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 285, "signature": "[[nodiscard]] std::string formatProfileSummaryLine(const ProfilerStats& stats)", "summary": "The CLI one-line summary (FR-11.1 \"exposed in ... the CLI\"): the run's counters plus the two window stat lines and the world-pulled fields, in one machine-greppable line (docs/api/engine.md, the laige-run section). Cold path (the two stats passes); allocates (a report string — reporting is never a hot path).", "budget": "O(n log n) cold path; allocates.", "experimental": false}, + {"name": "laige::formatProfileText", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 293, "signature": "[[nodiscard]] std::string formatProfileText(const Profiler& profiler, const World& world)", "summary": "The full report in the greppable text form (one section per line — the counters, the two windows, the world fields, and one line per registered system with its M1-SYS-03 window stats). Cold path; allocates. `world` is required (the per-system and world-pulled sections) — a moved-from / released world is not a legal argument.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, + {"name": "laige::formatProfileJson", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 299, "signature": "[[nodiscard]] std::string formatProfileJson(const Profiler& profiler, const World& world)", "summary": "The full report in the JSON form (version 1; schema documented in docs/api/profiler.md). Cold path; allocates.", "budget": "O(systemCount × n log n) cold path; allocates.", "experimental": false}, + {"name": "laige::writeProfile", "kind": "function", "header": "src/laige-sim/include/laige/sim/profiler.h", "line": 308, "signature": "[[nodiscard]] Result writeProfile( const Profiler& profiler, const World& world, std::string_view path, ProfileFormat format)", "summary": "Write the report to `path` (truncating; the file appears only when the write fully succeeds — no partial report on failure). Cold path (the format pass plus one file write). Every failure is a Result: unreadable/unwritable path -> IoError (CORE-008: never silent). Returns the bytes written on success.", "budget": "O(systemCount × n log n) cold path; allocates; one file write.", "experimental": false}, {"name": "laige::Access", "kind": "enum", "header": "src/laige-sim/include/laige/sim/query.h", "line": 234, "signature": "enum class Access : std::uint8_t", "summary": "The declared per-component access of a query (FR-1.3). Read: the component is only read during the iteration; Write: the system mutates it (through the query's reference or an in-place addComponent overwrite — both legal, see the preamble \"Iteration legality\"). M1-SYS-01's system I/O declarations reuse this value type.", "budget": null, "experimental": false}, {"name": "laige::Access::Read", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 235, "signature": "Read = 0", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access::Write", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 236, "signature": "Write = 1", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 7ea8a9b..9a17c47 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -238,7 +238,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R profiler` green; disabled-cost measurement recorded in `docs/benchmarks/baselines/m1-profiler-cost.md`. - **Size:** ~300 lines + tests -- [ ] **M1-PROF-02 · Frame graph / budget report** +- [x] **M1-PROF-02 · Frame graph / budget report** - **Refs:** FR-11.2; PRD §9.1 S-6 - **Depends:** M1-PROF-01, M0-CORE-08 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 054dab7..87d266e 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 | 21 | 🚧 in progress (M1-PROF-01) | +| M1 | 25 | 22 | 🚧 in progress (M1-ALLOC-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** | **39** | | +| **Total** | **193** | **40** | | --- @@ -214,6 +214,7 @@ One line per completed (or split/renumbered) step. | 2026-09-17 | M1-DET-04 | `—` | Bit-exactness CI (FR-1.4/11.5, NFR-8.3, PRD §14, ARCH-010, TEST-004, ADR 0002): activates `laige-detcheck` with the real M1-SAMPLE-01 scenario — committed per-tick hash-stream baselines in `samples/hello/baselines/` (one per SimMath backend; reference build: canonical Debug g++; 301 lines each), `hello --expect BASELINE` as the scenario-side equivalent of `laige-replay --expect` (the scenario binary carries the check — a game log cannot be replayed by laige-replay; same 0/1/2 contract, `hello::BaselineCheck` in `hello-baseline.cpp`), and the `hello-fp32` build variant (same source, `float_pinned_32` via the `LAIGE_HELLO_BACKEND` macros — one op surface, two backends); CI: every P0 OS job's ctest now asserts per-tick identity against the baselines on both backends (`hello_baseline_fpx`/`hello_baseline_fp32`) plus the failure fixtures (`hello_baseline_mismatch`/`_truncated`/`_malformed`/`_missing` — first-divergence, stream-length, malformed-line, missing-file paths), the merge `detcheck` job builds four configurations (Debug g++, Debug clang++, Debug+ASan clang++, Release g++) and runs the two-configuration pairs (pair A: g++ vs clang++ Debug; pair B: Debug+ASan vs Release — both backends, `--run-a/--run-b` + `--compare-combined`) after a reference-baseline sanity check, and the PR `detcheck` job runs the both-backend baseline comparison (tooling cadence, label-independent); local Verify: full `ctest` 82/82 on the canonical tree, all four detcheck pairs OK (ticks=301), byte-identical clang++ vs g++ streams (both backends), perturbation proof — `kVelocity` 1→2 in `MovePlayer` makes `hello --expect` exit 1 with "hash mismatch at tick 1 (first divergence)", the revert restores exit 0; determinism report recorded in `docs/benchmarks/determinism-matrix.md` (AGENTS §12 metadata, ARCH-010 scope statement, the `float_pinned_32` per-platform support list generated from matrix results — desynced pairs declared unsupported, never re-baselined; regeneration policy); `hello.cpp` stays at 99 code lines (PRD §9.4 — the baseline check and the fp32 variant live in separate TUs); docs updated in the same change (detcheck.md baseline-comparison section + CI status, hello README, building.md canonical rows, concepts/determinism.md cross-target, docs/README.md, tools/detcheck comment); commit lands with the step's PR (#41) | | 2026-09-20 | M1-DET-05 | `6b63465` / PR #44 | Replay diff (FR-11.3, M1-DET-05 scope, nothing else): `World::componentStateHash` (the PRNG-excluded canonical state hash — the replay diff's alignment key) + `World::stateDiff` (the bounded canonical-order state comparison) + `diffReplays` (the replay DIFF: identity-checked lock-step tick walk aligned on the component-state hash, first divergent tick + bounded state diff, length divergence, `fullStateDivergent` flag — the tick-37 integration scenario) + `laige-replay --diff `; `replay_diff` CTest entry; local Verify: `ctest -R replay_diff` green, full suite green on the canonical trees (board/changelog row retroactively added 2026-09-21 by the M1-CFG-01 PR — the step merged as `6b63465`/PR #44 without updating this board or log) | | 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 | diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index dd52751..ba9687a 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -82,11 +82,17 @@ # write; the public types and contract live in # include/laige/sim/profiler.h) plus the GameLoop Options::profiler # per-tick timing hook and the Engine's frame timing + per-run report -# finalization (game_loop.cpp, engine.cpp). +# finalization (game_loop.cpp, engine.cpp). M1-PROF-02 adds +# frame_budget.cpp: the frame graph / budget report (FrameBudgetRecord, +# the FrameBudgetRecorder fixed ring, buildFrameBudgetReport — the +# per-frame records against the declared budgets: system time, total +# tick time, allocation count — plus the Engine's per-frame recording +# and opt-in per-run report in engine.cpp; the public types and +# contract live in include/laige/sim/frame_budget.h). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp system_timing.cpp game_loop.cpp engine.cpp config.cpp replay.cpp state_hash.cpp - replay_diff.cpp profiler.cpp) + replay_diff.cpp profiler.cpp frame_budget.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 5918648..4be68d9 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -169,7 +169,20 @@ per-run report (`startProfileReport` / `profileStats()` / entry `profiler`; API contract in [docs/api/profiler.md](../docs/api/profiler.md); baseline in [docs/benchmarks/baselines/m1-profiler-cost.md](../docs/benchmarks/baselines/m1-profiler-cost.md)). -The per-frame budget report over the per-system windows -(M1-PROF-02), the editor overlay surface (M2), PRNG state -introspection (M1-DET-03), the detcheck matrix (M1-DET-04), and the -remaining M1 steps land next; physics, input, and animation in M3. +M1-PROF-02 landed the frame graph / budget report (FR-11.2) — +`FrameBudgetRecorder` (the fixed 32-frame ring), +`buildFrameBudgetReport` (every declared budget — each system's +declared `SystemDef` budget vs its M1-SYS-03 window's p99, the +`sim_tick_avg` / `sim_tick_p99` budgets vs the `Profiler`'s tick +window, the `sim_heap_allocs` hard-zero budget vs the per-frame sim +alloc deltas — measured vs declared with a pass/flag, plus the +over-budget systems list), the `Engine` per-frame accumulation + +opt-in cached per-run report (`startBudgetReport` / +`budgetReportRequested()` / `lastBudgetReport()`), and the +`laige-run --budget-report` / `--budgets` / `--fail-on-budget` +surface (CTest entries `budget_report`, `laige_run_budget`; API +contract in +[docs/api/frame_budget.md](../docs/api/frame_budget.md)). +The editor overlay surface (M2), the detcheck matrix (M1-DET-04), +and the remaining M1 steps land next; physics, input, and animation +in M3. diff --git a/src/laige-sim/engine.cpp b/src/laige-sim/engine.cpp index 77ab4ad..22d42a6 100644 --- a/src/laige-sim/engine.cpp +++ b/src/laige-sim/engine.cpp @@ -10,8 +10,10 @@ // laige-run CLI contract. // // Hot-path cost (per headless frame): one clock read, one bounded -// GameLoop::frame() dispatch, one snapshot onRenderFrame, one sleep — -// no allocation and no logging on the healthy path (PERF-003, +// GameLoop::frame() dispatch, one snapshot onRenderFrame, one +// budget record (M1-PROF-02: two O(1) reads, two O(systemCount) +// G-R5 counter passes, one O(1) ring write), one sleep — no +// allocation and no logging on the healthy path (PERF-003, // LOG-003; the per-frame breakdown in engine.h "Performance"). // Replay recording (M1-DET-02, opt-in debug builds only) adds one // bounded stdio write per completed tick when enabled, and one null @@ -25,6 +27,7 @@ #include "laige/sim/engine.h" // the Engine contract (this header) +#include #include #include #include @@ -136,6 +139,32 @@ inline constexpr const char* kReportAbortedMessage = "only — nothing to clean up); re-run the scenario with " "startProfileReport | docs/api/engine.md"; +// M1-PROF-02: the budget report messages (NFR-13.3 5-field grammar; +// the dynamic values are structured fields, never message text). +// Subsystem "budget" (LOG-001; the frame graph / budget report owns +// its event space, docs/api/frame_budget.md). +inline constexpr const char* kBudgetSubsystem = "budget"; + +inline constexpr const char* kBudgetReportPathInvalidMessage = + "report_path_invalid | the requested budget report path is empty " + "| the report needs a budgets.json path to load at start " + "(sim_tick_avg / sim_tick_p99 / sim_heap_allocs — PRD 8.1) | pass a " + "non-empty path (laige-run --budgets ) | " + "docs/api/frame_budget.md"; + +inline constexpr const char* kBudgetReportAlreadyStartedMessage = + "report_already_started | startBudgetReport was called twice | one " + "engine builds at most one budget report per run | call " + "startBudgetReport once, after all registration and before " + "run_headless | docs/api/frame_budget.md"; + +inline constexpr const char* kBudgetReportLoadFailedMessage = + "report_load_failed | the budgets.json table could not be loaded | " + "the file could not be read, or it is not the version 1 budget " + "schema | check the path and the schema " + "(docs/api/budget_harness.md) and re-run; the run has not " + "started | docs/api/frame_budget.md"; + } // namespace // --------------------------------------------------------------------------- @@ -377,6 +406,23 @@ Status Engine::run_headless(std::uint64_t maxTicks, laige::errorName(report.error()))); } } + // M1-PROF-02: the opt-in budget report, built BEFORE the shutdown + // (the world and the profiler are still live — the per-system + // windows' and the tick window's source) and CACHED in + // lastBudgetReport_ (the CLI reads the cache after the shutdown, + // the profileStats() precedent). Built on EVERY path when started — + // a failed run's report describes what actually happened (a + // zero-tick run gets a loud NO_SAMPLES report — CORE-008: no silent + // omission). A budget FAIL never fails the run (diagnostics never + // gate the simulation): the caller gates (laige-run maps + // --fail-on-budget to exit 3). + if (budgetReportStarted_) { + FrameBudgetReportOptions reportOptions; + reportOptions.lastNFrames = budgetReportLastN_; + reportOptions.context.workload = "headless"; + lastBudgetReport_ = buildFrameBudgetReport( + budgetRecorder_, *profiler_, *world_, budgetTable_, reportOptions); + } // The loop's accounting BEFORE it is destroyed in shutdown (the // profiler feed; zeros when the loop never existed). lastStats_ = (loop_ != nullptr) ? loop_->stats() : GameLoopStats{}; @@ -396,9 +442,53 @@ Status Engine::run_headless(std::uint64_t maxTicks, return runStatus; } +// M1-PROF-02: the per-system G-R5 event totals (the sum of the +// systemTimingStats warns/errors counters — frame_budget.cpp's +// per-frame deltas subtract the frame's baseline from these). +// O(systemCount); no allocation (the SystemTimingStats value copy — +// the World::systemTimingStats contract). +void budgetEventTotals(const World* world, std::uint64_t* warns, + std::uint64_t* errors) { + *warns = 0; + *errors = 0; + for (std::uint32_t id = 1; id <= world->systemCount(); ++id) { + const SystemId sid{id}; + const Result stats = + world->systemTimingStats(sid); + // The id comes from the world's own dense count: unreachable. + assert(stats.ok() && "systemTimingStats for a valid dense id"); + *warns += stats.value().warns; + *errors += stats.value().errors; + } +} + +void Engine::recordFrameBudget(std::uint64_t frameIndex, + std::uint64_t ticksBefore, + std::uint64_t allocsBefore, + std::uint64_t warnsBefore, + std::uint64_t errorsBefore, + double frameMs) noexcept { // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured frame time never enters sim state, hashes, or replays (M1-PROF-02, ARCH-009) + FrameBudgetRecord record; + record.frame = frameIndex; + record.tickAfter = loop_->currentTick(); + record.ticks = static_cast(record.tickAfter - ticksBefore); + record.frameMs = frameMs; + // The frame's sim allocation count: the pool-reservations delta + // (frame_budget.h — the M1-ECS-03 accounting; 0 = steady state). + record.simAllocs = + world_->archetypeStats().totalReservations - allocsBefore; + std::uint64_t warnsAfter = 0; + std::uint64_t errorsAfter = 0; + budgetEventTotals(world_.get(), &warnsAfter, &errorsAfter); + record.overrunWarns = static_cast(warnsAfter - warnsBefore); + record.criticalErrors = + static_cast(errorsAfter - errorsBefore); + budgetRecorder_.recordFrame(record); +} + // The frame drive: bounded, paced, allocation-free (engine.h // "Performance"). One clock read, one loop frame, one snapshot -// refresh, one sleep per frame. +// refresh, one budget record, one sleep per frame. Status Engine::runFrames(std::uint64_t maxTicks) noexcept { const std::int64_t startNs = loop_->startReferenceNs(); const std::int64_t rate = static_cast(loop_->tickRateHz()); @@ -409,6 +499,9 @@ Status Engine::runFrames(std::uint64_t maxTicks) noexcept { // nothing else (DBG-004). Profiler* prof = profiler_.get(); const bool timing = (prof != nullptr) && prof->enabled(); + // M1-PROF-02: the run's frame index (0-based over the run frames — + // the start-reference first frame is not one of them). + std::uint64_t frameIndex = 0; for (;;) { // M1-DET-02: a replay-recording failure stops the run (at most // one frame's worth of ticks runs after the failing write — the @@ -416,6 +509,17 @@ Status Engine::runFrames(std::uint64_t maxTicks) noexcept { if (replayFail_.isError()) return replayFail_; if (maxTicks != 0 && loop_->currentTick() >= maxTicks) break; const std::int64_t now = steadyNowNs(); + // M1-PROF-02: the frame budget baseline (the cheap reads BEFORE + // the frame — frame_budget.h "The per-frame record"). + const std::uint64_t ticksBefore = loop_->currentTick(); + const std::uint64_t allocsBefore = + world_->archetypeStats().totalReservations; + std::uint64_t warnsBefore = 0; + std::uint64_t errorsBefore = 0; + budgetEventTotals(world_.get(), &warnsBefore, &errorsBefore); + // M1-PROF-02: the frame's sim work (0.0 when the profiler is + // disabled — no measurement, no clock reads — DBG-004). + double frameMs = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured frame time never enters sim state, hashes, or replays (M1-PROF-02, ARCH-009) if (timing) { const TimeIt timer; const Status frameStatus = loop_->frame(); @@ -429,7 +533,8 @@ Status Engine::runFrames(std::uint64_t maxTicks) noexcept { if (snapshot_.hasSnapshot()) { snapshot_.onRenderFrame(snapshot_.context, now); } - prof->recordFrame(timer.elapsedMs()); + frameMs = timer.elapsedMs(); + prof->recordFrame(frameMs); } else { const Status frameStatus = loop_->frame(); if (frameStatus.isError()) return frameStatus; @@ -438,6 +543,13 @@ Status Engine::runFrames(std::uint64_t maxTicks) noexcept { snapshot_.onRenderFrame(snapshot_.context, now); } } + // M1-PROF-02: record this completed frame (after the sim work and + // the presentation refresh, before pacing — the SAME frame the + // profiler records; a failed frame is not recorded — the + // frame did not complete). + recordFrameBudget(frameIndex, ticksBefore, allocsBefore, warnsBefore, + errorsBefore, frameMs); + ++frameIndex; if (maxTicks != 0 && loop_->currentTick() >= maxTicks) { break; // no sleep after the final tick (a bounded run ends) } @@ -579,6 +691,67 @@ Status Engine::startProfileReport(std::string_view path) noexcept { return Status{}; } +// --------------------------------------------------------------------------- +// The frame graph / budget report (M1-PROF-02; the contract in +// engine.h "The frame graph / budget report", frame_budget.h, and +// docs/api/frame_budget.md) +// --------------------------------------------------------------------------- + +Status Engine::startBudgetReport(std::string_view budgetsPath, + std::uint32_t lastNFrames) noexcept { + // The report is diagnostics, not replay state: EVERY build (no + // NDEBUG gate — like startProfileReport). + // A stopped engine (shutdown or moved-from) is a no-op failure + // without logging (the stopped-state precedent). + if (shutDown_ || world_ == nullptr) { + return Status(ErrorCode::InvalidArgument); + } + if (budgetsPath.empty()) { + LAIGE_LOG_WARN(kBudgetSubsystem, "report_path_invalid", + kBudgetReportPathInvalidMessage); + return Status(ErrorCode::InvalidArgument); + } + if (budgetReportStarted_) { + LAIGE_LOG_WARN(kBudgetSubsystem, "report_already_started", + kBudgetReportAlreadyStartedMessage); + return Status(ErrorCode::InvalidArgument); + } + // The budgets.json table (M0-CORE-08, schema v1): bounded read + + // parse (the setup path — reporting is never a hot path). The report + // evaluates its sim_tick_avg / sim_tick_p99 / sim_heap_allocs + // entries (frame_budget.h). + Result table = loadBudgets(budgetsPath); + if (table.isError()) { + LAIGE_LOG_ERROR(kBudgetSubsystem, "report_load_failed", + kBudgetReportLoadFailedMessage, + laige::log::field("path", std::string(budgetsPath)), + laige::log::field("error", + laige::errorName(table.error()))); + return Status(table.error()); + } + budgetTable_ = std::move(table).takeValue(); + // 0 = all retained (the FrameBudgetReportOptions default); a larger + // value clamps to the window at build time (buildFrameBudgetReport). + budgetReportLastN_ = (lastNFrames == 0) + ? kFrameBudgetWindow + : std::min(lastNFrames, kFrameBudgetWindow); + budgetReportStarted_ = true; + LAIGE_LOG_INFO(kBudgetSubsystem, "report_started", + "Budget report requested (built at run end, cached)", + laige::log::field("budgets", std::string(budgetsPath)), + laige::log::field("last_frames", budgetReportLastN_), + laige::log::field("systems", world_->systemCount())); + return Status{}; +} + +bool Engine::budgetReportRequested() const noexcept { + return budgetReportStarted_; +} + +const FrameBudgetReport& Engine::lastBudgetReport() const noexcept { + return lastBudgetReport_; +} + // --------------------------------------------------------------------------- // Replay recording (M1-DET-02; the contract in engine.h "Replay // recording" and docs/api/replay.md) @@ -658,6 +831,11 @@ Engine::Engine(Engine&& other) noexcept profileReportPath_(std::move(other.profileReportPath_)), profileReportStatus_(other.profileReportStatus_), profileReportFinalized_(other.profileReportFinalized_), + budgetRecorder_(other.budgetRecorder_), + budgetTable_(std::move(other.budgetTable_)), + budgetReportStarted_(other.budgetReportStarted_), + budgetReportLastN_(other.budgetReportLastN_), + lastBudgetReport_(std::move(other.lastBudgetReport_)), shutDown_(other.shutDown_) { // The source becomes a STOPPED engine (the GameLoop moved-out // precedent): nothing left to release, nothing to flush. Its @@ -690,6 +868,11 @@ Engine& Engine::operator=(Engine&& other) noexcept { profileReportPath_ = std::move(other.profileReportPath_); profileReportStatus_ = other.profileReportStatus_; profileReportFinalized_ = other.profileReportFinalized_; + budgetRecorder_ = other.budgetRecorder_; + budgetTable_ = std::move(other.budgetTable_); + budgetReportStarted_ = other.budgetReportStarted_; + budgetReportLastN_ = other.budgetReportLastN_; + lastBudgetReport_ = std::move(other.lastBudgetReport_); shutDown_ = other.shutDown_; other.shutDown_ = true; } diff --git a/src/laige-sim/frame_budget.cpp b/src/laige-sim/frame_budget.cpp new file mode 100644 index 0000000..0fda40d --- /dev/null +++ b/src/laige-sim/frame_budget.cpp @@ -0,0 +1,319 @@ +// laige-sim frame graph / budget report implementation (M1-PROF-02; +// PRD FR-11.2, §9.1 S-6, §9.3 G-R5). +// +// Implementation of the API declared in +// include/laige/sim/frame_budget.h — see that header (the declared +// budgets, the per-frame record, the report layout and pass/flag +// semantics) and docs/api/frame_budget.md for the full API contract. +// +// Hot-path cost: recordFrame is one ring write plus the engine's +// per-frame counter reads (engine.h — no allocation, no logging). +// Everything here is cold path: at() / reset() are O(1)/O(window) +// reads, and buildFrameBudgetReport formats the report (allocates — +// reporting is never a hot path, the profiler.cpp / budget_harness.cpp +// precedent). + +#include "laige/sim/frame_budget.h" + +#include +#include +#include +#include +#include +#include + +#include "laige/budget_harness.h" // budgetCheck, formatStatsLine +#include "laige/fpx16_16.h" // fpx16_16::toFloat (the declared budgets) + +namespace laige { + +namespace { + +// The three declared budgets.json entries the frame report evaluates +// (PRD §8.1 hard budgets — the stable names, LOG-001). +inline constexpr const char* kSimTickAvgBudget = "sim_tick_avg"; +inline constexpr const char* kSimTickP99Budget = "sim_tick_p99"; +inline constexpr const char* kSimHeapAllocsBudget = "sim_heap_allocs"; + +// Locale-free rendering of a double for the report: at most 6 +// significant digits (%.6g, "C" locale); NaN/inf render as plain +// "nan"/"inf" text so the report stays greppable (the +// budget_harness.cpp formatDouble precedent). +void formatDouble(std::string& out, double value) { // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: report rendering of measured time (M1-PROF-02, ARCH-009) + char buf[32]; + if (std::isnan(value)) { + std::snprintf(buf, sizeof(buf), "nan"); + } else if (std::isinf(value)) { + std::snprintf(buf, sizeof(buf), value > 0.0 ? "inf" : "-inf"); // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic sign test (M1-PROF-02, ARCH-009) + } else { + std::snprintf(buf, sizeof(buf), "%.6g", value); + } + out += buf; +} + +// One string field (name + text + one separating space — the AGENTS +// §12 context line; the numeric fields use std::to_string inline). +void appendField(std::string& out, const char* name, std::string_view value) { + out += name; + out += value; + out += ' '; +} + +// The greppable stats line of one window: the formatStatsLine fields +// when non-empty (its stable `stats: ` prefix stripped — the report +// line carries its own context), the NaN-free "n=0" form when empty +// (the report must never emit NaN text into a machine-readable line — +// the profiler.cpp windowLine precedent, LOG-001). +std::string windowLine(const HistogramStats& s) { + if (s.n == 0) return "n=0"; + const std::string line = formatStatsLine(s); + static const std::string_view prefix = "stats: "; + return line.substr(prefix.size()); +} + +} // namespace + +void FrameBudgetRecorder::recordFrame(FrameBudgetRecord record) noexcept { + ring_[cursor_] = record; + cursor_ = (cursor_ + 1) % kFrameBudgetWindow; + if (count_ < kFrameBudgetWindow) ++count_; + ++total_; +} + +void FrameBudgetRecorder::reset() noexcept { + for (FrameBudgetRecord& record : ring_) record = FrameBudgetRecord{}; + cursor_ = 0; + count_ = 0; + total_ = 0; +} + +std::uint64_t FrameBudgetRecorder::totalFrames() const noexcept { + return total_; +} + +std::uint32_t FrameBudgetRecorder::count() const noexcept { return count_; } + +const FrameBudgetRecord& FrameBudgetRecorder::at(std::uint32_t i) const noexcept { + // The caller reads 0..count() (the header contract). + assert(i < count_); + // Oldest-first head (the header's private-storage note). + const std::uint64_t head = (total_ - count_) % kFrameBudgetWindow; + return ring_[(head + i) % kFrameBudgetWindow]; +} + +FrameBudgetReport buildFrameBudgetReport( + const FrameBudgetRecorder& recorder, const Profiler& profiler, + const World& world, const BudgetTable& budgets, + const FrameBudgetReportOptions& options) { + FrameBudgetReport out; + std::string r; + + // Header (the stable version marker, LOG-001). + r += "laige-budget-report version=1\n"; + + // The AGENTS §12 caller context (the M0-CORE-08 report format). + r += "laige-budget-report context: "; + appendField(r, "workload=", options.context.workload); + appendField(r, "build=", options.context.build); + appendField(r, "machine=", options.context.machine); + r += "warmup="; + r += std::to_string(options.context.warmup); + r += "\n"; + + const std::uint32_t retained = recorder.count(); + const std::uint32_t lastN = (options.lastNFrames == 0) + ? kFrameBudgetWindow + : std::min(options.lastNFrames, kFrameBudgetWindow); + const std::uint32_t shown = std::min(retained, lastN); + + r += "laige-budget-report frames: n="; + r += std::to_string(shown); + r += " total="; + r += std::to_string(recorder.totalFrames()); + r += "\n"; + + // Overall pass/flag (every section folds into it; NO_SAMPLES / + // NO_ENTRY count as failures — a broken harness is loud, not + // green — CORE-008). + bool overall = true; + + // The per-frame lines (oldest → newest, the last `shown` frames). + for (std::uint32_t i = 0; i < shown; ++i) { + const FrameBudgetRecord& rec = recorder.at(retained - shown + i); + const bool framePass = + (rec.simAllocs == 0) && (rec.overrunWarns == 0) && + (rec.criticalErrors == 0); + overall = overall && framePass; + r += "laige-budget-report frame="; + r += std::to_string(rec.frame); + r += " ticks="; + r += std::to_string(rec.ticks); + r += " tick_after="; + r += std::to_string(rec.tickAfter); + r += " frame_ms="; + formatDouble(r, rec.frameMs); + r += " sim_allocs="; + r += std::to_string(rec.simAllocs); + r += " overrun_warns="; + r += std::to_string(rec.overrunWarns); + r += " critical_errors="; + r += std::to_string(rec.criticalErrors); + r += " result="; + r += framePass ? "PASS" : "FAIL"; + r += "\n"; + } + + // The declared total-tick-time budgets (PRD §8.1 sim_tick_avg / + // sim_tick_p99) over the Profiler's tick window — the M0-CORE-08 + // budgetCheck, whose AGENTS §12 report block is embedded verbatim. + for (const char* name : {kSimTickAvgBudget, kSimTickP99Budget}) { + const BudgetEntry* entry = budgets.find(name); + if (entry == nullptr) { + // A missing entry is a configuration error, not an empty + // workload — loud, never silent (CORE-008). + r += "budget="; + r += name; + r += " result=NO_ENTRY\n"; + overall = false; + continue; + } + const BudgetCheckResult check = + budgetCheck(*entry, profiler.tickWindow(), options.context); + overall = overall && check.passed; + r += check.report; + } + + // The declared allocation-count budget (PRD §8.1 sim_heap_allocs, + // target 0 — the hard-zero at-most semantics) over the per-frame + // sim-alloc deltas of the retained window (a cold, local histogram — + // reporting is never a hot path). An empty window (no completed + // frames) is the loud NO_SAMPLES state. + { + const BudgetEntry* entry = budgets.find(kSimHeapAllocsBudget); + if (entry == nullptr) { + r += "budget="; + r += kSimHeapAllocsBudget; + r += " result=NO_ENTRY\n"; + overall = false; + } else { + Histogram allocs(Histogram::Options{static_cast(shown)}); + for (std::uint32_t i = 0; i < shown; ++i) { + // The per-frame sim-alloc delta as a measured sample. + allocs.record(static_cast( // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: allocation count rendered as a measured sample (M1-PROF-02, ARCH-009) + recorder.at(retained - shown + i).simAllocs)); + } + const BudgetCheckResult check = + budgetCheck(*entry, allocs, options.context); + overall = overall && check.passed; + r += check.report; + } + } + + // The per-system section: each system's DECLARED budget + // (SystemDef::budgetMs, M1-SYS-01) vs its M1-SYS-03 rolling window's + // p99 (the statistic the G-R5 warn carries), plus the G-R5 event + // counters (the per-frame deltas fold into the frame lines above). + std::uint32_t overCount = 0; + for (std::uint32_t id = 1; id <= world.systemCount(); ++id) { + const SystemId sid{id}; + const Result infoResult = world.system(sid); + if (infoResult.isError()) { + // Unreachable: the id comes from the world's own dense count. + assert(!infoResult.isError() && "system(id) for a valid dense id"); + continue; + } + const SystemInfo& info = infoResult.value(); + const Result timingResult = + world.systemTimingStats(sid); + assert(timingResult.ok() && "systemTimingStats for a valid dense id"); + const SystemTimingStats& timing = timingResult.value(); + const Histogram* window = world.systemTimingWindow(sid); + const HistogramStats stats = (window != nullptr) ? window->stats() + : HistogramStats{}; + // The declared budget (fpx16_16 ms, exact — ADR 0002). + const double budgetMs = // LAIGE-DETERM-EXCEPTION: G-R8 declared budget value, not sim math (M1-PROF-02) + fpx16_16::toFloat(info.def.budgetMs); + // Measured vs declared (at-most): an empty window (the system + // never ran) is NO_SAMPLES (loud — the budgetCheck precedent); + // p99 > budget is a sustained overrun (FAIL). + const char* result; + if (stats.n == 0) { + result = "NO_SAMPLES"; + overall = false; + } else if (stats.p99 > budgetMs) { + result = "FAIL"; + overall = false; + ++overCount; + } else { + result = "PASS"; + } + r += "laige-budget-report system id="; + r += std::to_string(sid.value); + r += " name="; + r += (info.def.name != nullptr ? info.def.name : ""); + r += " budget_ms="; + formatDouble(r, budgetMs); + r += " runs="; + r += std::to_string(timing.runs); + r += " last_ms="; + formatDouble(r, timing.lastMs); + r += " measured_p99_ms="; + if (stats.n > 0) { + formatDouble(r, stats.p99); + } else { + r += "nan"; + } + r += " result="; + r += result; + r += " warns="; + r += std::to_string(timing.warns); + r += " errors="; + r += std::to_string(timing.errors); + r += " window: "; + r += windowLine(stats); + r += "\n"; + } + + // The over-budget systems list (FR-11.2 "over-budget systems + // flagged"): one line per FAIL system, ascending id, machine- + // greppable; `none` when the section is clean. + if (overCount == 0) { + r += "laige-budget-report over_budget: none\n"; + } else { + for (std::uint32_t id = 1; id <= world.systemCount(); ++id) { + const SystemId sid{id}; + const Result infoResult = world.system(sid); + if (infoResult.isError()) { + assert(!infoResult.isError() && "system(id) for a valid dense id"); + continue; + } + const SystemInfo& info = infoResult.value(); + const Histogram* window = world.systemTimingWindow(sid); + if (window == nullptr) continue; + const HistogramStats stats = window->stats(); + const double budgetMs = // LAIGE-DETERM-EXCEPTION: G-R8 declared budget value, not sim math (M1-PROF-02) + fpx16_16::toFloat(info.def.budgetMs); + if (stats.n > 0 && stats.p99 > budgetMs) { + r += "laige-budget-report over_budget: id="; + r += std::to_string(sid.value); + r += " name="; + r += (info.def.name != nullptr ? info.def.name : ""); + r += " p99_ms="; + formatDouble(r, stats.p99); + r += " budget_ms="; + formatDouble(r, budgetMs); + r += "\n"; + } + } + } + + r += "laige-budget-report overall="; + r += overall ? "PASS" : "FAIL"; + r += "\n"; + + out.passed = overall; + out.report = std::move(r); + return out; +} + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/engine.h b/src/laige-sim/include/laige/sim/engine.h index abb159d..86c5953 100644 --- a/src/laige-sim/include/laige/sim/engine.h +++ b/src/laige-sim/include/laige/sim/engine.h @@ -255,6 +255,46 @@ // and report_already_started and report_path_invalid (Warn). // // --------------------------------------------------------------------------- +// The frame graph / budget report (M1-PROF-02, FR-11.2) +// --------------------------------------------------------------------------- +// +// The per-frame budget records are ALWAYS accumulated (cheap — +// frame_budget.h "The per-frame record": two O(1) reads (the loop's +// tick count, the pool reservations), two O(systemCount) passes over +// the per-system G-R5 counters, one O(1) ring write per frame — no +// allocation, no logging; PERF-003, LOG-003). The REPORT is +// opt-in and available in EVERY build (diagnostics, not replay +// state — like the profile report): +// +// startBudgetReport(budgetsPath) called after all registration +// and before run_headless (like startProfileReport — one report +// per run). It loads the budgets.json table (M0-CORE-08, schema +// v1) and stores it; the report is BUILT (and cached) at the +// END of the run on EVERY path — success, failed frame, and +// failed start alike: the report describes what actually +// happened (a zero-tick run gets a zero-tick report, loud +// NO_SAMPLES lines included). A budget FAIL does NOT fail the +// run (diagnostics never gate the simulation): the caller +// decides — laige-run maps --fail-on-budget to its exit-3 +// budget class. +// +// The report evaluates the declared budgets (frame_budget.h): +// each system's declared SystemDef budget vs its M1-SYS-03 rolling +// window's p99 (over-budget systems flagged, the same counters the +// G-R5 warn/error events count), the PRD §8.1 sim_tick_avg / +// sim_tick_p99 entries vs the Profiler's tick window, and the +// sim_heap_allocs hard-zero budget vs the per-frame sim-alloc +// deltas. The machine-greppable text follows the AGENTS §12 field +// format (docs/api/frame_budget.md). +// +// The last run's report is cached in the engine (lastBudgetReport()) +// for the same reason as profileStats(): the world — and with it the +// per-system windows' source — is released in the shutdown. The +// engine emits the structured budget/* events (LOG-001/002): +// report_started (Info), report_path_invalid and +// report_already_started (Warn), report_load_failed (Error). +// +// --------------------------------------------------------------------------- // Ownership, threading // --------------------------------------------------------------------------- // @@ -301,6 +341,15 @@ // (one rate-limited warn per overload frame — the M1-LOOP-01 // contract); the report finalization (startProfileReport) is cold // too (one format pass + one file write, once per run). +// Frame graph / budget report (M1-PROF-02): the per-frame +// accumulation adds two O(1) reads (the loop's tick count, the pool +// reservations), two O(systemCount) passes over the per-system G-R5 +// counters, and one O(1) ring write per completed frame — no +// allocation and no logging (PERF-003, LOG-003); the ring is fixed +// storage created in Engine::create (the engine's setup, not the +// run's). The report build + cache (buildFrameBudgetReport at the +// run's end) is cold (one O(systemCount × n log n) format pass, once +// per run — the budget_harness.cpp precedent). // // --------------------------------------------------------------------------- // Misuse warnings @@ -335,6 +384,16 @@ // (profiler/report_already_started). A report write failure does // NOT fail the run — check profileReportStatus() (laige-run maps // it to exit 2). +// - The budget report is opt-in (startBudgetReport) and ONE per +// run, called after all registration and before run_headless (a +// second call fails — budget/report_already_started). A budget +// FAIL does NOT fail the run: lastBudgetReport().passed is the +// gate (laige-run --fail-on-budget maps overall=FAIL to exit 3). +// Note the budgets.json path is resolved by the CALLER (laige-run +// does --budgets arg, the LAIGE_BUDGETS_PATH env, then +// "budgets.json" in the working directory — the laige-bench +// precedent); a load failure is a start error (exit 2), not a +// run failure. #pragma once @@ -349,6 +408,7 @@ #include "laige/sim/config.h" // EngineConfig, the version 1 schema (M1-CFG-01) #include "laige/sim/determinism.h" // SimMathBackend, DeterminismConfig (M1-DET-01) #include "laige/sim/entity.h" // World, kDefaultChurnPerFrameBudget +#include "laige/sim/frame_budget.h" // FrameBudgetRecorder/Report (M1-PROF-02) #include "laige/sim/game_loop.h" // GameLoop, GameLoopStats, tick-rate constants #include "laige/sim/presentation.h" // Position2D, PresentationSnapshot #include "laige/sim/profiler.h" // Profiler, ProfilerStats (M1-PROF-01) @@ -631,6 +691,61 @@ class Engine { // shutdown's abandonment). O(1), no side effects. [[nodiscard]] bool profileReportActive() const noexcept; + // Start the opt-in per-run budget report (M1-PROF-02, FR-11.2 — + // the header preamble "The frame graph / budget report"; the + // report's format and pass/flag semantics in frame_budget.h, + // docs/api/frame_budget.md). EVERY build (diagnostics, not replay + // state — like startProfileReport). + // + // Call after all component/system registration and before + // run_headless. `budgetsPath` is the budgets.json file (schema v1 + // — M0-CORE-08): the report evaluates its sim_tick_avg / + // sim_tick_p99 / sim_heap_allocs entries plus every registered + // system against its declared SystemDef budget. The table is + // loaded at start (bounded read + parse — cold, setup path); the + // report is built and CACHED at the end of the run (the world and + // the profiler are released in the shutdown). + // + // stopped engine (already shut down) -> InvalidArgument (no log — + // the stopped-state + // precedent) + // empty path -> InvalidArgument + warn + // (budget/report_path_ + // invalid) + // already started -> InvalidArgument + warn + // (budget/report_ + // already_started) + // budgets.json unreadable/malformed -> IoError / MalformedInput + // + warn (budget/ + // report_load_failed) + // + // A budget FAIL at the end of the run does NOT fail the run — + // lastBudgetReport().passed is the caller's gate (laige-run + // --fail-on-budget maps overall=FAIL to exit 3). + // + // `lastNFrames` bounds the report's per-frame section to the last + // N retained frames (1..kFrameBudgetWindow; 0 = + // kFrameBudgetWindow — all retained, the FrameBudgetReportOptions + // default). The engine builds the report with this bound at the + // run's end. + // @budget O(file read + parse) setup path; no per-tick cost. + [[nodiscard]] Status startBudgetReport( + std::string_view budgetsPath, + std::uint32_t lastNFrames = kFrameBudgetWindow) noexcept; + + // True when the budget report was started (lastBudgetReport() is + // the meaningful read after a run). O(1), no side effects. + [[nodiscard]] bool budgetReportRequested() const noexcept; + + // The last run's budget report (frame_budget.h): the overall + // pass/flag plus the machine-greppable text (AGENTS §12 field + // format). All-zero/empty before the first run (the profileStats() + // zero-state precedent); read it after a run, on every run path + // (a failed run's report describes what happened — loud + // NO_SAMPLES lines included). O(1), no allocation, no side + // effects. + [[nodiscard]] const FrameBudgetReport& lastBudgetReport() const noexcept; + // Move transfers the owned state; the source becomes a STOPPED // engine (world() nullptr, run_headless fails, shutdown is a no-op // — the GameLoop moved-out precedent). @@ -662,6 +777,18 @@ class Engine { // stops at maxTicks — 0 = run until the process ends). [[nodiscard]] Status runFrames(std::uint64_t maxTicks) noexcept; + // M1-PROF-02: the per-frame budget record (frame_budget.h): the + // frame's scalars (the tick delta, the frame's sim work — 0.0 when + // the profiler is disabled — the pool-reservations delta, the G-R5 + // event deltas) into the fixed ring. Hot path: no allocation, no + // logging (PERF-003, LOG-003). + void recordFrameBudget(std::uint64_t frameIndex, + std::uint64_t ticksBefore, + std::uint64_t allocsBefore, + std::uint64_t warnsBefore, + std::uint64_t errorsBefore, + double frameMs) noexcept; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured frame time never enters sim state, hashes, or replays (M1-PROF-02, ARCH-009) + // The owned state (all released in shutdown, in the documented // order). std::unique_ptr world_; @@ -703,6 +830,20 @@ class Engine { std::string profileReportPath_; Status profileReportStatus_{}; bool profileReportFinalized_{false}; + // Frame graph / budget report (M1-PROF-02): the per-frame ring + // (fixed storage — created in Engine::create, the engine's setup, + // not the run's), the loaded budgets.json table (empty while not + // started — the M0-CORE-08 BudgetTable), the started flag, and the + // last run's cached report (built at the run's end, before the + // shutdown releases the world and the profiler — the + // profileStats() cache precedent). + FrameBudgetRecorder budgetRecorder_{}; + BudgetTable budgetTable_{}; + bool budgetReportStarted_{false}; + // The started report's per-frame bound (startBudgetReport's + // lastNFrames — the report build's FrameBudgetReportOptions). + std::uint32_t budgetReportLastN_{kFrameBudgetWindow}; + FrameBudgetReport lastBudgetReport_{}; // True after shutdown() has run (or on a moved-from engine). bool shutDown_{false}; }; diff --git a/src/laige-sim/include/laige/sim/frame_budget.h b/src/laige-sim/include/laige/sim/frame_budget.h new file mode 100644 index 0000000..e999059 --- /dev/null +++ b/src/laige-sim/include/laige/sim/frame_budget.h @@ -0,0 +1,305 @@ +// laige-sim frame graph / budget report (M1-PROF-02; PRD FR-11.2, +// §9.1 S-6, §9.3 G-R5). +// +// FR-11.2 (frame graph / budget report): per-frame breakdown against +// declared budgets; over-budget systems flagged. This header ships +// the headless half of that surface: the cheap per-frame records the +// Engine accumulates in its run loop, the fixed window over them, and +// the cold report that evaluates every declared budget (system time, +// total tick time, allocation count) — measured vs declared, with a +// pass/flag per budget and the over-budget systems listed (the same +// counters the M1-SYS-03 G-R5 warn/error events count). +// +// FrameBudgetRecord one completed frame's cheap budget scalars +// FrameBudgetRecorder the fixed ring over the last frames (no +// allocation after construction) +// kFrameBudgetWindow the default (and max) retained frame count +// FrameBudgetReport the evaluated report: the overall pass/flag +// plus the formatted text +// FrameBudgetReportOptions +// the report's options (the retained frame +// count, the AGENTS §12 caller context) +// buildFrameBudgetReport +// the cold report builder (allocates — +// reporting is never a hot path) +// +// --------------------------------------------------------------------------- +// Declared budgets (the "declared" side of measured vs declared) +// --------------------------------------------------------------------------- +// +// Three declared budget families feed the report: +// +// - system time: each system's DECLARED per-tick time budget — +// SystemDef::budgetMs (M1-SYS-01, fpx16_16 ms, exact). The +// config's system_time_default_ms (BudgetsConfig) is the declared +// default for systems that do not declare one; M1 systems always +// declare one (registration rejects a zero/negative budget), so +// the report evaluates each system against its own declared +// budget. +// - total tick time: the PRD §8.1 hard budgets sim_tick_avg (mean +// tick time, ms) and sim_tick_p99 (p99 tick time, ms) — the named +// budgets.json entries (M0-CORE-08) evaluated against the +// Profiler's tick-time window (the M1-PROF-01 per-completed-tick +// samples). +// - allocation count: the PRD §8.1 hard-zero budget sim_heap_allocs +// (heap allocations per frame, target 0) evaluated against the +// per-frame sim allocation deltas (the pool accounting's +// totalReservations delta, World::archetypeStats — M1-ECS-03; +// M1-ALLOC-01 will refine the per-frame count once it exists). +// +// --------------------------------------------------------------------------- +// The per-frame record (cheap, hot path) +// --------------------------------------------------------------------------- +// +// The Engine's run loop (engine.h run_headless) records one +// FrameBudgetRecord per COMPLETED frame — the same frames the +// Profiler records (a failed frame is not a completed frame; the +// start-reference first frame is not a run frame — the M1-PROF-01 +// run loop contract). The fields: +// +// frame the 0-based index of the frame within the run's +// run frames (the start-reference frame is not one +// of them) +// tickAfter the completed tick count after this frame (the +// cumulative tick index; 0..maxTicks) +// ticks the completed ticks WITHIN this frame +// (tickAfter minus the previous frame's tickAfter) +// frameMs the frame's sim work + presentation refresh, in +// ms — the exact value handed to the Profiler's +// recordFrame (0.0 when the profiler is disabled: +// no measurement, no clock reads — DBG-004) +// simAllocs the frame's sim allocation count: the +// World::archetypeStats().totalReservations delta +// across the frame (0 = the steady-state target) +// overrunWarns the system/budget_overrun WARN events (G-R5) +// issued during this frame — the per-frame delta +// of the per-system warn counters (M1-SYS-03) +// criticalErrors the system/budget_critical ERROR events (G-R5) +// issued during this frame (as above) +// +// Record cost (PERF-003, LOG-003): two O(1) counter reads (the loop's +// tick count, the pool reservations), two O(systemCount) passes over +// the per-system timing counters (the G-R5 warn/error deltas), one +// O(1) ring write — no allocation, no logging. The measured times are +// diagnostics (ARCH-009): they never enter authoritative simulation +// state, hashes, or replays. +// +// --------------------------------------------------------------------------- +// The report (cold path) +// --------------------------------------------------------------------------- +// +// buildFrameBudgetReport evaluates the declared budgets against the +// run's measurements and formats the machine-greppable report +// (AGENTS §12 field format — the same field layout the M0-CORE-08 +// budgetCheck emits for the budgets.json entries): +// +// laige-budget-report version=1 +// laige-budget-report context: workload=... build=... machine=... warmup=... +// laige-budget-report frames: n= total= +// laige-budget-report frame= ticks= tick_after= frame_ms= +// sim_allocs= overrun_warns= critical_errors= result=PASS|FAIL +// budget=sim_tick_avg result= metric=mean unit=ms +// after= before= target= +// stats: n=... min=... mean=... p50=... p95=... p99=... max=... +// context: workload=... build=... machine=... warmup=... +// budget=sim_tick_p99 ... +// budget=sim_heap_allocs ... +// laige-budget-report system id= name= budget_ms= +// measured_p99_ms= result= warns= errors= +// window: n=... ... +// laige-budget-report over_budget: id= name= p99_ms= budget_ms= +// (one line per over-budget system, ascending id; `none` when none) +// laige-budget-report overall=PASS|FAIL +// +// Pass/flag semantics (the budgets are AT-MOST upper bounds — the +// M0-CORE-08 convention): +// +// - a frame: result=FAIL iff sim_allocs > 0 (the hard-zero +// allocation budget) or a G-R5 event fired during the frame +// (overrunWarns / criticalErrors > 0 — the frame's ticks exceeded +// a declared system budget). frame_ms is informational (no +// declared per-frame tick budget exists in M1 — the tick budgets +// are rolling statistics, evaluated over the window below). +// - sim_tick_avg / sim_tick_p99: the M0-CORE-08 budgetCheck over the +// Profiler's tick window (an empty window is a NO_SAMPLES +// failure — never silent, CORE-008); a missing budgets.json +// entry is a NO_ENTRY failure (a configuration error, loud). +// - sim_heap_allocs: the M0-CORE-08 budgetCheck (metric max) over a +// histogram of the retained frames' sim_allocs deltas (an empty +// window — no completed frames — is NO_SAMPLES; a missing entry +// is NO_ENTRY). +// - a system: measured = its M1-SYS-03 rolling window's p99 (the +// same statistic the G-R5 warn carries); result=FAIL iff p99 > +// the declared budget (a sustained overrun — a single +// over-budget tick that recovered stays visible in the warns / +// errors counters and the per-frame results); an empty window +// (the system never ran) is NO_SAMPLES. +// - overall: PASS iff every frame, every budget entry, and every +// system passed (NO_SAMPLES / NO_ENTRY count as failures — a +// broken harness is loud, not green). +// +// `passed` mirrors the overall line. The report is the operator +// surface (laige-run --budget-report, docs/api/frame_budget.md); +// CI gates on it through --fail-on-budget (exit 3 on overall=FAIL). +// +// Ownership (CONC-001): the recorder is a value type with one owner +// thread while mutable (the Engine's run thread); the report reads +// it cold after the run (the engine builds the cached report before +// the ordered shutdown releases the world and the profiler). + +#pragma once + +#include +#include +#include +#include + +#include "laige/budget_harness.h" // BudgetTable, BudgetReportContext, Histogram +#include "laige/sim/entity.h" // World (the cold pull: systems, windows, pools) +#include "laige/sim/profiler.h" // Profiler (the tick window, M1-PROF-01) +#include "laige/sim/system.h" // SystemId (the per-system section) + +namespace laige { + +// One completed frame's cheap budget scalars (M1-PROF-02). Fixed +// storage, since-construction units documented in the header +// preamble. A record is a value: built by the Engine's run loop and +// stored in the FrameBudgetRecorder ring (no pointer, no lifetime +// question — CPP-002). +struct FrameBudgetRecord { + // The 0-based index of the frame within the run's run frames (the + // start-reference first frame is not one of them — the M1-PROF-01 + // run loop contract). + std::uint64_t frame{}; + // The completed tick count after this frame (the cumulative tick + // index; a bounded run ends with tickAfter == the run target). + std::uint64_t tickAfter{}; + // The completed ticks WITHIN this frame (0 when the frame ran ahead + // of the tick rate; several in a catch-up frame — the M1-LOOP-01 + // accumulator). + std::uint32_t ticks{}; + // The frame's sim work + presentation refresh in ms (0.0 when the + // profiler is disabled — no measurement — the preamble). + double frameMs{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured frame time never enters sim state, hashes, or replays (M1-PROF-02, ARCH-009) + // The frame's sim allocation count (the pool-reservations delta; + // 0 = the steady-state target — the PRD §8.1 hard-zero budget). + std::uint64_t simAllocs{}; + // The G-R5 system/budget_overrun WARN events issued during this + // frame (the per-frame delta of the per-system warn counters). + std::uint32_t overrunWarns{}; + // The G-R5 system/budget_critical ERROR events issued during this + // frame (as above). + std::uint32_t criticalErrors{}; +}; + +// The number of frames the FrameBudgetRecorder retains (CORE-005). At +// the default 60 Hz tick rate the window spans ~0.53 s — long enough +// for a frame graph to show a regression, small enough that the ring +// (32 × 48 B) is negligible fixed storage. 0 is not a capacity for +// the ring itself (a 0-frame report is the loud NO_SAMPLES state — +// the budgetCheck precedent); kFrameBudgetWindow is the default and +// the max for FrameBudgetReportOptions::lastNFrames. +inline constexpr std::uint32_t kFrameBudgetWindow = 32; + +// The fixed ring over the last frames' records (M1-PROF-02). The +// Engine owns one (setup path: no allocation — the ring is a fixed +// array member; the profiler's fixed-storage precedent). +class FrameBudgetRecorder { + public: + // Construct the recorder (setup path): no allocation (the fixed + // ring), nothing recorded (count() 0, totalFrames() 0). + FrameBudgetRecorder() = default; + + // Record one completed frame (hot path — the Engine's run loop). + // O(1): one ring write; no allocation, no logging (PERF-003, + // LOG-003). Beyond kFrameBudgetWindow the OLDEST record is dropped + // (the M0-CORE-08 window semantics); totalFrames() keeps counting + // every recorded frame, so a truncated window is observable + // (CORE-008: silent truncation is not allowed). + // @budget O(1); no allocation (hot path). + void recordFrame(FrameBudgetRecord record) noexcept; + + // Clear the ring and the counters (idempotent). Cold path (the + // tests; a run never resets mid-flight). + // @budget O(kFrameBudgetWindow) cold path; no allocation. + void reset() noexcept; + + // The frames recorded since construction / the last reset (not + // truncated — CORE-008). + // @budget O(1); no allocation. + [[nodiscard]] std::uint64_t totalFrames() const noexcept; + + // The records currently retained (0..kFrameBudgetWindow). + // @budget O(1); no allocation. + [[nodiscard]] std::uint32_t count() const noexcept; + + // The retained record at position `i` in OLDEST-FIRST order + // (0 = the oldest retained frame; `i` in 0..count() - 1). The last + // min(totalFrames(), kFrameBudgetWindow) recorded frames in time + // order (the M0-CORE-08 rolling-window order). Points into the + // recorder's fixed storage (valid until the next recordFrame / + // reset). The ring wraps, so the retained records are not a + // contiguous span — read them through this accessor (the cold + // report's loop, the tests). + // @budget O(1); no allocation. + [[nodiscard]] const FrameBudgetRecord& at(std::uint32_t i) const noexcept; + + private: + // The ring: record i (in write order) sits at ring_[i mod window], + // so after total_ writes the oldest RETAINED record sits at + // ring_[(total_ - count_) mod window] — the oldest-first head + // (at() adds the offset and wraps). All three counters are fixed + // state (no separate head pointer — nothing to keep consistent). + FrameBudgetRecord ring_[kFrameBudgetWindow]{}; + // The one-past-the-next-write index in ring_ (0..window); + // total_ % window. + std::size_t cursor_{0}; + // The records currently retained (0..window). + std::uint32_t count_{0}; + // The frames recorded since construction / reset (unbounded). + std::uint64_t total_{0}; +}; + +// The report options (API-006). +struct FrameBudgetReportOptions { + // The number of frames to include, oldest first (1.. + // kFrameBudgetWindow; 0 = kFrameBudgetWindow — all retained). + // Frames beyond the retained window are not recoverable (the ring + // dropped them — CORE-008). + std::uint32_t lastNFrames{kFrameBudgetWindow}; + // The AGENTS §12 caller context for the report (the workload / + // build / machine the numbers were measured on, the warmup sample + // count — the M0-CORE-08 BudgetReportContext). + BudgetReportContext context{}; +}; + +// The evaluated report (M1-PROF-02): the overall pass/flag plus the +// formatted text (the header preamble's layout). A value: the Engine +// caches the last run's report (cold string — reporting is never a +// hot path). +struct FrameBudgetReport { + // The overall pass/flag (the `overall=` line): false when any frame, + // budget entry, or system failed (NO_SAMPLES / NO_ENTRY included). + bool passed{}; + // The machine-greppable report text (AGENTS §12 field format). + std::string report; +}; + +// 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). +// @budget O(systemCount × n log n + window) cold path; allocates +// (the report string). +[[nodiscard]] FrameBudgetReport buildFrameBudgetReport( + const FrameBudgetRecorder& recorder, const Profiler& profiler, + const World& world, const BudgetTable& budgets, + const FrameBudgetReportOptions& options = {}); + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/profiler.h b/src/laige-sim/include/laige/sim/profiler.h index 4efd818..9fecd5e 100644 --- a/src/laige-sim/include/laige/sim/profiler.h +++ b/src/laige-sim/include/laige/sim/profiler.h @@ -49,7 +49,9 @@ // no duplicated state, one source of truth each: // // - per-system time histograms: World::systemTimingWindow(id) -// (M1-SYS-03) — the report reads them per system +// (M1-SYS-03) — the report reads them per system (and the +// frame graph's budget report, frame_budget.h — M1-PROF-02 — +// reads the tick window itself through tickWindow()) // - entity counts (total / alive): World::stats() (inUse / // totalCreated, M1-ECS-01) // - sim alloc count (sum of the pool accounting, target 0): @@ -222,6 +224,15 @@ class Profiler { // @budget O(n log n) cold path; no allocation. [[nodiscard]] HistogramStats frameTime() const noexcept; + // The tick-time window itself (the M1-PROF-02 frame graph's budget + // checks read it cold through the M0-CORE-08 budgetCheck — the + // header preamble's "the per-frame budget report over them is + // M1-PROF-02"). Non-owning const view into the profiler's fixed + // window (valid until the profiler is destroyed or moved — the + // profiler is owned by the Engine for its whole lifetime). + // @budget O(1); no allocation. + [[nodiscard]] const Histogram& tickWindow() const noexcept; + // The snapshot of the profiler's own counters (no world access). // Cold path (the two stats passes); no side effects. // @budget O(n log n) cold path; no allocation. diff --git a/src/laige-sim/profiler.cpp b/src/laige-sim/profiler.cpp index c8778bc..93fbe9c 100644 --- a/src/laige-sim/profiler.cpp +++ b/src/laige-sim/profiler.cpp @@ -199,6 +199,8 @@ ProfilerStats Profiler::snapshot(const World& world) const noexcept { return s; } +const Histogram& Profiler::tickWindow() const noexcept { return tickWindow_; } + bool Profiler::enabled() const noexcept { return enabled_; } void Profiler::setEnabled(bool on) noexcept { enabled_ = on; } diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 373ebe7..f04cf53 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): +# + 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 @@ -40,7 +40,14 @@ # rollover, and no-op-when-disabled, the cold world-pulled snapshot, # the GameLoop's per-completed-tick timing, the engine's per-run cache # + opt-in JSON report, the record path's zero-allocation, and the -# enabled-cost check bounded at 1% of a 10k-entity tick). +# enabled-cost check bounded at 1% of a 10k-entity tick), and the +# frame graph / budget report (M1-PROF-02: the FrameBudgetRecorder +# fixed ring (newest frames oldest-first, the record path's +# zero-allocation), buildFrameBudgetReport's declared-budget +# 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)). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus @@ -49,20 +56,20 @@ # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, # `system_timing`, `game_loop`, `presentation`, `engine`, # `game_config`, `determinism_mode`, `replay_record`, `replay_replay`, -# `replay_diff`, and `profiler` entries +# `replay_diff`, `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, and M1-PROF-01 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`, +# 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`, and `ctest -R -# profiler`), selecting exactly the suites below from the shared -# executable. +# `ctest -R replay_replay`, `ctest -R replay_diff`, `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 archetype_tests.cpp query_tests.cpp @@ -79,7 +86,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp replay_record_tests.cpp replay_replay_tests.cpp replay_diff_tests.cpp - profiler_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, @@ -292,6 +300,16 @@ add_test(NAME profiler COMMAND laige-sim_tests --gtest_filter=Profiler*) +# M1-PROF-02: the frame graph / budget report (FR-11.2, §9.1 S-6). +# The step's Verify command is `ctest -R budget_report`; this entry +# selects exactly the BudgetReport* suites from the shared +# laige-sim_tests executable (the machine-greppable +# budget-report-overbudget / budget-report-engine / +# budget-report-recorder-zeroalloc lines land in the ctest output). +add_test(NAME budget_report + COMMAND laige-sim_tests + --gtest_filter=BudgetReport*) + # M1-DET-01: the G-R8 trait compile-checks (the compile-time half of # the determinism guarantee). Each fixture is compiled (not linked, # not run) with the engine policy flags; the positive fixture must @@ -356,6 +374,7 @@ 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 PROPERTIES + replay_record replay_replay replay_diff profiler budget_report + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/budget_report_tests.cpp b/tests/laige-sim/budget_report_tests.cpp new file mode 100644 index 0000000..e221aa1 --- /dev/null +++ b/tests/laige-sim/budget_report_tests.cpp @@ -0,0 +1,1004 @@ +// laige-sim frame graph / budget report suite (M1-PROF-02; PRD +// FR-11.2, §9.1 S-6, §9.3 G-R5). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - a synthetic over-budget system appears in the report with +// correct numbers (the declared budget echoed, the measured p99 +// above it, the runs/warns/errors counters exact, the +// over_budget: line, overall=FAIL) +// - the declared budgets' semantics: healthy world passes +// (overall=PASS, over_budget: none), a missing budgets.json +// entry is a loud NO_ENTRY, and a zero-tick run is a loud +// NO_SAMPLES (never silent — CORE-008) +// - the Engine wiring: the opt-in report is built and CACHED at the +// end of the run on every run path (readable after the shutdown), +// the G-R5 events fold into the per-frame records (the +// overrun_warns / critical_errors deltas), and the start +// validation (empty path / double start / stopped engine / +// load failure) +// - the FrameBudgetRecorder ring: the fixed window keeps the +// newest frames oldest-first and its record path allocates +// nothing (PERF-003, non-sanitizer trees) +// +// The threshold assertions are preemption-tolerant (the +// system_timing_tests precedent): shared-runner deschedules can only +// STRETCH a measured run (never shorten it), so the synthetic burn +// (2 ms vs a 0.1 ms budget) is chosen to exceed BOTH documented +// G-R5 multipliers (1× and 3×) on its nominal floor — the warn and +// the critical fire on every completed tick whatever preemption +// stretches, so the counter assertions are exact. A bounded run +// lands EXACTLY on maxTicks (the frame budget 1 contract — a late +// frame drops its extra due tick, it never overshoots), so runs == +// maxTicks is exact too; the per-frame distribution can vary under +// drops, so the per-frame assertions branch on each frame's own +// tick count. +// +// Runs as CTest `budget_report` (the step's Verify command: +// `ctest -R budget_report`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the suites below. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for _fsopen +#endif + +#include "gtest/gtest.h" +#include "laige/budget_harness.h" +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim/entity.h" +#include "laige/sim/engine.h" +#include "laige/sim/frame_budget.h" +#include "laige/sim/profiler.h" +#include "laige/sim/system.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the +// build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "budget_report_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "budget_report_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, + "budget_report_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 BUDGET_REPORT_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define BUDGET_REPORT_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +static_assert(BUDGET_REPORT_TESTS_ACTIVE_CPLUSPLUS >= 202002L, + "budget_report_tests must be built with C++20 " + "(NFR-8.10); see laige_apply_engine_policy()."); + +// LAIGE_COMPONENT specializes laige::detail::ComponentTraits, which +// must be specialized at global scope (the component_registry_tests +// pattern). +struct BRTag { + std::int32_t v{}; +}; +LAIGE_COMPONENT(BRTag) +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(BRTag, std::int32_t) + +namespace { + +using laige::Access; +using laige::ErrorCode; +using laige::FrameBudgetRecord; +using laige::FrameBudgetRecorder; +using laige::FrameBudgetReport; +using laige::Io; +using laige::Profiler; +using laige::Result; +using laige::Status; +using laige::SystemDef; +using laige::SystemSchedule; +using laige::World; + +// --------------------------------------------------------------------------- +// The synthetic systems (the system_timing_tests burn pattern) +// --------------------------------------------------------------------------- + +// The burn target (ms) of BRBurn — test plumbing: the tests set it +// before running a tick (the sim is single-threaded, PRD §10.2). 0 +// means a healthy no-burn run. +double brBurnMs = 0.0; + +// The burn loop's accumulator (namespace scope: written, never read — +// the loop's work must not be eliminated, and a namespace-scope +// variable carries no unused-variable diagnostic). +volatile std::uint64_t gBurnSink = 0; + +// Burn roughly `ms` milliseconds of wall time (the system_timing +// pattern). +void burnMs(double ms) { + if (ms <= 0.0) return; + const auto start = std::chrono::steady_clock::now(); + while (std::chrono::duration_cast>( + std::chrono::steady_clock::now() - start).count() < ms) { + gBurnSink = gBurnSink + 1; + } +} + +LAIGE_SYSTEM(BRBurn, 1) +void BRBurn(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); + burnMs(brBurnMs); +} + +LAIGE_SYSTEM(BRNoop, 1) +void BRNoop(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +// --------------------------------------------------------------------------- +// World + def builders +// --------------------------------------------------------------------------- + +World makeWorld() { + auto w = World::create(World::Options{16}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(16) failed: " + << laige::errorName(w.error()); + abort(); + } + World world = std::move(w).takeValue(); + if (!world.registerComponent().ok()) { + ADD_FAILURE() << "registerComponent failed"; + abort(); + } + return world; +} + +SystemDef makeDef(const char* name, laige::SystemFn fn, + laige::fpx16_16 budgetMs) { + return SystemDef{name, fn, budgetMs, nullptr}; +} + +// The synthetic burn's declared budget (ms): 0.1 — the burn floor +// (2 ms) is 20× it, so BOTH G-R5 multipliers (1× warn, 3× critical) +// are exceeded nominally and stay exceeded under any preemption +// stretch (the preemption-tolerance note above). +laige::fpx16_16 burnBudgetMs() { return laige::fpx16_16::fromFloat(0.1f); } + +// --------------------------------------------------------------------------- +// The test budgets.json files (schema v1 — written to the CWD by the +// tests, removed afterward; the paths are unique per file so parallel +// CTest entries in the same CWD never collide). +// --------------------------------------------------------------------------- + +inline constexpr const char* kHealthyBudgetsPath = "brt_healthy_budgets.json"; +inline constexpr const char* kNoAllocsBudgetsPath = "brt_noallocs_budgets.json"; +inline constexpr const char* kBadVersionBudgetsPath = + "brt_badversion_budgets.json"; + +// Open a fixture file (the profiler_tests.cpp openReportFile / +// replay.cpp precedent): MSVC's CRT deprecates plain `fopen` +// (C4996, fatal under the engine's /WX policy). As in replay.cpp, +// the MSVC path uses `_fsopen(path, mode, _SH_DENYNO)` — plain-`fopen` +// sharing semantics (the secure `fopen_s` opens with `_SH_SECURE` +// and would deny it). +#if defined(_MSC_VER) +std::FILE* openBudgetsFile(const char* path, const char* mode) { + return ::_fsopen(path, mode, _SH_DENYNO); +} +#else +std::FILE* openBudgetsFile(const char* path, const char* mode) { + return std::fopen(path, mode); +} +#endif + +// The version-1 table the report's tick/alloc budgets need: generous +// tick targets (1000 ms — the tick budgets are NOT the test subject; +// the per-system declared budget is) and the hard-zero allocation +// budget (the record path's zero-alloc property, sim_heap_allocs). +// The per-system section evaluates the SystemDef budgets, not +// budgets.json — so the synthetic system's FAIL is the only FAIL +// source in the over-budget test (machine-deterministic). +bool writeHealthyBudgets(const char* path) { + const char* text = + "{\n" + " \"version\": 1,\n" + " \"description\": \"M1-PROF-02 test budgets (brt suite)\",\n" + " \"budgets\": [\n" + " { \"name\": \"sim_tick_avg\", \"metric\": \"mean\",\n" + " \"unit\": \"ms\", \"target\": 1000.0, \"measured\": 0,\n" + " \"workload\": \"test\" },\n" + " { \"name\": \"sim_tick_p99\", \"metric\": \"p99\",\n" + " \"unit\": \"ms\", \"target\": 1000.0, \"measured\": 0,\n" + " \"workload\": \"test\" },\n" + " { \"name\": \"sim_heap_allocs\", \"metric\": \"max\",\n" + " \"unit\": \"allocs_per_frame\", \"target\": 0, \"measured\": 0,\n" + " \"workload\": \"test\" }\n" + " ]\n" + "}\n"; + std::remove(path); // clean a crashed previous run's leftover + std::FILE* f = openBudgetsFile(path, "wb"); + if (f == nullptr) return false; + const bool ok = + std::fwrite(text, 1, std::strlen(text), f) == std::strlen(text); + std::fclose(f); + return ok; +} + +// The same table WITHOUT the sim_heap_allocs entry (the NO_ENTRY +// test's fixture). +bool writeNoAllocsBudgets(const char* path) { + const char* text = + "{\n" + " \"version\": 1,\n" + " \"description\": \"M1-PROF-02 test budgets without sim_heap_allocs\",\n" + " \"budgets\": [\n" + " { \"name\": \"sim_tick_avg\", \"metric\": \"mean\",\n" + " \"unit\": \"ms\", \"target\": 1000.0, \"measured\": 0,\n" + " \"workload\": \"test\" },\n" + " { \"name\": \"sim_tick_p99\", \"metric\": \"p99\",\n" + " \"unit\": \"ms\", \"target\": 1000.0, \"measured\": 0,\n" + " \"workload\": \"test\" }\n" + " ]\n" + "}\n"; + std::remove(path); + std::FILE* f = openBudgetsFile(path, "wb"); + if (f == nullptr) return false; + const bool ok = + std::fwrite(text, 1, std::strlen(text), f) == std::strlen(text); + std::fclose(f); + return ok; +} + +// An unsupported schema version (the load-failure fixture). +bool writeBadVersionBudgets(const char* path) { + const char* text = "{\"version\": 99, \"budgets\": []}\n"; + std::remove(path); + std::FILE* f = openBudgetsFile(path, "wb"); + if (f == nullptr) return false; + const bool ok = + std::fwrite(text, 1, std::strlen(text), f) == std::strlen(text); + std::fclose(f); + return ok; +} + +laige::BudgetTable loadTableOrDie(const char* path) { + Result table = laige::loadBudgets(path); + if (table.isError()) { + ADD_FAILURE() << "loadBudgets(" << path << ") failed: " + << laige::errorName(table.error()); + abort(); + } + return std::move(table).takeValue(); +} + +// --------------------------------------------------------------------------- +// Report text helpers (machine-greppable line parsing) +// --------------------------------------------------------------------------- + +// True when `text` contains `needle`. +bool contains(const std::string& text, std::string_view needle) { + return text.find(needle) != std::string::npos; +} + +// The single line of `text` that starts with `prefix` (up to the +// newline), empty when absent. +std::string lineWith(const std::string& text, std::string_view prefix) { + const std::size_t pos = text.find(prefix); + if (pos == std::string::npos) return {}; + // Back up to the line start (the prefix may sit mid-line if it + // appeared earlier — not the case for the report's prefixes, but + // the back-up keeps this helper total). + const std::size_t start = text.rfind('\n', pos) + 1; + const std::size_t end = text.find('\n', start); + return text.substr(start, end == std::string::npos ? std::string::npos + : end - start); +} + +// Count the lines of `text` that start with `prefix`. +std::size_t countLinesWith(const std::string& text, std::string_view prefix) { + std::size_t n = 0; + std::size_t from = 0; + for (;;) { + const std::size_t end = text.find('\n', from); + if (text.compare(from, prefix.size(), prefix.data(), prefix.size()) == + 0) { + ++n; + } + if (end == std::string::npos) break; + from = end + 1; + } + return n; +} + +// Count the non-overlapping occurrences of `needle` in `text` (the +// budgetCheck result= lines sit mid-block, not at line starts). +std::size_t countOccurrences(const std::string& text, + std::string_view needle) { + std::size_t n = 0; + std::size_t from = 0; + for (;;) { + const std::size_t pos = text.find(needle, from); + if (pos == std::string::npos) break; + ++n; + from = pos + needle.size(); + } + return n; +} + +// The numeric field `name=` of one report line (parsed as a +// double); returns NaN when the field is absent (the caller +// assert-expects presence — a NaN comparison is false and reads +// loudly wrong). +double fieldOf(const std::string& line, const char* name) { + const std::string needle = std::string(name) + "="; + const std::size_t pos = line.find(needle); + if (pos == std::string::npos) return std::numeric_limits::quiet_NaN(); + const std::size_t start = pos + needle.size(); + char* endp = nullptr; + const double v = std::strtod(line.c_str() + start, &endp); + if (endp == nullptr || (*endp != ' ' && *endp != '\0')) { + return std::numeric_limits::quiet_NaN(); + } + return v; +} + +// The report's lines (newline-separated; the trailing newline ends +// the last line). +std::vector splitLines(const std::string& text) { + std::vector lines; + std::size_t from = 0; + for (;;) { + const std::size_t end = text.find('\n', from); + lines.push_back(text.substr(from, end == std::string::npos + ? std::string::npos + : end - from)); + if (end == std::string::npos) break; + from = end + 1; + } + return lines; +} + +// The unsigned field `name=` of one report line (the +// frame lines' counters); 0 when the field is absent (a present +// field always parses — the format is fixed, frame_budget.cpp). +std::uint64_t uintField(const std::string& line, const char* name) { + const std::string needle = std::string(name) + "="; + const std::size_t pos = line.find(needle); + if (pos == std::string::npos) return 0; + const std::size_t start = pos + needle.size(); + char* endp = nullptr; + const std::uint64_t v = std::strtoull(line.c_str() + start, &endp, 10); + if (endp == nullptr || (*endp != ' ' && *endp != '\0')) return 0; + return v; +} + +// --------------------------------------------------------------------------- +// Log capture (the profiler_tests pattern: Warn and up only, rate +// limiting OFF — the G-R5 counters are logged every occurrence) +// --------------------------------------------------------------------------- + +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + }; + + void emit(const laige::log::LogRecord& record) override { + if (record.severity < laige::log::Severity::Warn) return; + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto mem = std::make_unique(); + MemorySink* ptr = mem.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(mem); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + abort(); + } + return ptr; +} + +void restoreLogger() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger::init (restore default sink) failed"; + } +} + +std::size_t countEvents(const MemorySink& sink, std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.event == event) ++n; + } + return n; +} + +// Creates an engine, failing loudly on a setup error (the +// profiler_tests makeEngine pattern). +laige::Engine makeEngine(laige::EngineConfig config) { + Result r = laige::Engine::create(config); + if (!r.ok()) { + ADD_FAILURE() << "Engine::create failed: " << laige::errorName(r.error()); + abort(); + } + return std::move(r).takeValue(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// The FrameBudgetRecorder ring (the fixed per-frame window) +// --------------------------------------------------------------------------- + +TEST(BudgetReport, RecorderKeepsTheNewestFramesOldestFirst) { + FrameBudgetRecorder rec; + EXPECT_EQ(rec.count(), 0u); + EXPECT_EQ(rec.totalFrames(), 0u); + + const std::uint64_t total = laige::kFrameBudgetWindow + 8; // 40 + for (std::uint64_t i = 0; i < total; ++i) { + FrameBudgetRecord r; + r.frame = i; + r.tickAfter = i; + r.ticks = 1; + r.frameMs = 0.001 * static_cast(i + 1); + r.simAllocs = 0; + r.overrunWarns = 0; + r.criticalErrors = 0; + rec.recordFrame(r); + } + + // The ring is bounded: the newest kFrameBudgetWindow frames are + // retained, oldest-first; totalFrames keeps counting. + EXPECT_EQ(rec.count(), laige::kFrameBudgetWindow); + EXPECT_EQ(rec.totalFrames(), total); + EXPECT_EQ(rec.at(0).frame, static_cast(total - rec.count())); + EXPECT_EQ(rec.at(rec.count() - 1).frame, total - 1); + EXPECT_NEAR(rec.at(rec.count() - 1).frameMs, 0.001 * static_cast(total), + 1e-9); + + // Below capacity: every frame is retained. + FrameBudgetRecorder small; + for (std::uint64_t i = 0; i < 4; ++i) { + FrameBudgetRecord r; + r.frame = i; + small.recordFrame(r); + } + EXPECT_EQ(small.count(), 4u); + EXPECT_EQ(small.totalFrames(), 4u); + EXPECT_EQ(small.at(0).frame, 0u); + EXPECT_EQ(small.at(3).frame, 3u); + + // reset(): back to the fresh state. + rec.reset(); + EXPECT_EQ(rec.count(), 0u); + EXPECT_EQ(rec.totalFrames(), 0u); +} + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(BudgetReport, RecorderRecordPathAllocatesNothing) { + // The ring is fixed storage created with the object: recording past + // the window boundary (the wrap) touches no heap (PERF-003). The + // record path is what the engine's per-frame hot path pays. + FrameBudgetRecorder warm; + warm.recordFrame(FrameBudgetRecord{}); // one-time state, pre-window + FrameBudgetRecorder rec; + laige::test::resetAllocCounter(); + for (std::uint64_t i = 0; i < 1000; ++i) { + FrameBudgetRecord r; + r.frame = i; + r.tickAfter = i; + r.ticks = 1; + r.simAllocs = 0; + rec.recordFrame(r); + } + const std::uint64_t allocs = laige::test::allocCounter(); + std::printf("budget-report-recorder-zeroalloc frames=1000 allocs=%llu\n", + static_cast(allocs)); + EXPECT_EQ(rec.count(), laige::kFrameBudgetWindow); + EXPECT_EQ(rec.totalFrames(), 1000u); + EXPECT_EQ(allocs, 0u); +} +#endif + +// --------------------------------------------------------------------------- +// buildFrameBudgetReport: the healthy world (overall=PASS) +// --------------------------------------------------------------------------- + +TEST(BudgetReport, HealthyWorldReportPasses) { + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + World w = makeWorld(); + // Budget 100 ms: a noop system's microsecond ticks stay under it + // (the system_timing_tests noise-floor precedent for 100 ms + // budgets — a stretched noop tick is a legitimate diagnostic + // value, and it would carry its own G-R5 event, which the + // zero-event assertions below reject). + ASSERT_TRUE(w.registerSystem(makeDef("BRNoop", &BRNoop, + laige::fpx16_16::fromInt32(100)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + + Profiler prof(Profiler::Options{}); + FrameBudgetRecorder rec; + for (std::uint32_t i = 0; i < 10; ++i) { + w.beginFrame(); + ASSERT_TRUE(w.runSystems(sched).ok()); + // The tick window's samples (10 us — far under the 1000 ms + // test targets) and the per-frame record (a healthy frame). + prof.recordTick(0.01); + FrameBudgetRecord r; + r.frame = i; + r.tickAfter = i; + r.ticks = 1; + r.frameMs = 0.01; + r.simAllocs = 0; // the steady state: no sim allocations + rec.recordFrame(r); + } + + const laige::BudgetTable budgets = loadTableOrDie(kHealthyBudgetsPath); + const FrameBudgetReport report = buildFrameBudgetReport(rec, prof, w, + budgets); + std::remove(kHealthyBudgetsPath); + + EXPECT_TRUE(report.passed); + EXPECT_TRUE(contains(report.report, "laige-budget-report version=1")); + EXPECT_TRUE(contains(report.report, "frames: n=10 total=10")); + // All three declared budgets pass (the M0-CORE-08 report block, + // embedded verbatim — one line each here). + EXPECT_TRUE( + contains(report.report, + "budget=sim_tick_avg result=PASS metric=mean unit=ms")); + EXPECT_TRUE( + contains(report.report, + "budget=sim_tick_p99 result=PASS metric=p99 unit=ms")); + EXPECT_TRUE(contains( + report.report, + "budget=sim_heap_allocs result=PASS metric=max unit=allocs_per_frame")); + // The per-system section: the noop system's declared budget vs its + // rolling window — PASS, zero events. + const std::string sys = + lineWith(report.report, "laige-budget-report system id=1 name=BRNoop "); + ASSERT_FALSE(sys.empty()); + EXPECT_TRUE(contains(sys, "budget_ms=100")); + EXPECT_TRUE(contains(sys, "runs=10")); + EXPECT_TRUE(contains(sys, "result=PASS")); + EXPECT_TRUE(contains(sys, "warns=0")); + EXPECT_TRUE(contains(sys, "errors=0")); + // The over-budget list is empty, and the overall folds to PASS. + EXPECT_TRUE(contains(report.report, "laige-budget-report over_budget: none")); + EXPECT_TRUE(contains(report.report, "laige-budget-report overall=PASS")); +} + +// --------------------------------------------------------------------------- +// buildFrameBudgetReport: the synthetic over-budget system (the step's +// "synthetic over-budget system ... with correct numbers") +// --------------------------------------------------------------------------- + +TEST(BudgetReport, OverBudgetSystemAppearsWithCorrectNumbers) { + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + World w = makeWorld(); + // Declared budget 0.1 ms (fpx16_16, exact — ADR 0002), burn floor + // 2 ms: 20× the budget — both G-R5 multipliers exceeded nominally + // and under any preemption stretch (the file-header note). + ASSERT_TRUE(w.registerSystem(makeDef("BRBurn", &BRBurn, burnBudgetMs()), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + + const std::uint32_t kTicks = 10; + Profiler prof(Profiler::Options{}); + FrameBudgetRecorder rec; + brBurnMs = 2.0; + for (std::uint32_t i = 0; i < kTicks; ++i) { + w.beginFrame(); + ASSERT_TRUE(w.runSystems(sched).ok()); + // The tick window samples track the burn (~2 ms each — far under + // the 1000 ms test targets, so the tick budgets stay PASS and + // the system budget is the report's only FAIL source). + prof.recordTick(2.0); + // The per-frame record: one burn tick per frame, one warn and one + // critical per tick (the G-R5 events — 2 ms >= 1x0.1 ms and + // 2 ms >= 3x0.1 ms on the nominal floor). + FrameBudgetRecord r; + r.frame = i; + r.tickAfter = i; + r.ticks = 1; + r.frameMs = 2.0; + r.simAllocs = 0; + r.overrunWarns = 1; + r.criticalErrors = 1; + rec.recordFrame(r); + } + brBurnMs = 0.0; + + const laige::BudgetTable budgets = loadTableOrDie(kHealthyBudgetsPath); + const FrameBudgetReport report = buildFrameBudgetReport(rec, prof, w, + budgets); + std::remove(kHealthyBudgetsPath); + + // The overall: FAIL (a FAIL system — the tick/alloc budgets and the + // frames' sim_allocs stay clean, so this is the system section). + EXPECT_FALSE(report.passed); + EXPECT_TRUE(contains(report.report, "laige-budget-report overall=FAIL")); + + // The per-frame section: every recorded frame ran the burn — one + // warn + one critical, every frame FAIL. + EXPECT_EQ(countLinesWith(report.report, "laige-budget-report frame="), 10u); + for (const std::string& line : splitLines(report.report)) { + if (line.rfind("laige-budget-report frame=", 0) != 0) continue; + EXPECT_EQ(uintField(line, "ticks"), 1u); + EXPECT_EQ(uintField(line, "sim_allocs"), 0u); + EXPECT_EQ(uintField(line, "overrun_warns"), 1u); + EXPECT_EQ(uintField(line, "critical_errors"), 1u); + EXPECT_TRUE(contains(line, "result=FAIL")); + } + + // The per-system section: the declared budget echoed (0.1 ms, the + // fpx16_16 rounding to 16.16 — ~0.100006), runs exact, the + // measured p99 above the budget, the G-R5 counters exact (10 + // warns + 10 criticals — both multipliers exceeded on the + // nominal floor), result=FAIL. + const std::string sys = + lineWith(report.report, "laige-budget-report system id=1 name=BRBurn "); + ASSERT_FALSE(sys.empty()); + const double sysBudget = fieldOf(sys, "budget_ms"); + EXPECT_NEAR(sysBudget, 0.1, 1e-4); + EXPECT_TRUE(contains(sys, "runs=10")); + const double p99 = fieldOf(sys, "measured_p99_ms"); + EXPECT_GE(p99, 2.0); + EXPECT_TRUE(contains(sys, "result=FAIL")); + EXPECT_TRUE(contains(sys, "warns=10")); + EXPECT_TRUE(contains(sys, "errors=10")); + + // The over-budget systems list: one line for the FAIL system, + // ascending id, with the p99 and the echoed budget. + const std::string ob = + lineWith(report.report, "laige-budget-report over_budget: id=1 "); + ASSERT_FALSE(ob.empty()); + EXPECT_TRUE(contains(ob, "name=BRBurn ")); + EXPECT_GE(fieldOf(ob, "p99_ms"), 2.0); + EXPECT_NEAR(fieldOf(ob, "budget_ms"), 0.1, 1e-4); + EXPECT_EQ(countLinesWith(report.report, "laige-budget-report over_budget: " + "id="), + 1u); + + // The machine-greppable summary (the ctest output, the + // profiler-zeroalloc precedent). + std::printf( + "budget-report-overbudget runs=%u warns=10 errors=10 " + "p99_ms=%.6g budget_ms=%.6g overall=FAIL\n", + static_cast(kTicks), p99, sysBudget); +} + +// --------------------------------------------------------------------------- +// The declared-budget semantics: NO_ENTRY and NO_SAMPLES (loud, never +// silent — CORE-008) +// --------------------------------------------------------------------------- + +TEST(BudgetReport, MissingBudgetEntryIsNoEntry) { + ASSERT_TRUE(writeNoAllocsBudgets(kNoAllocsBudgetsPath)); + World w = makeWorld(); + ASSERT_TRUE(w.registerSystem(makeDef("BRNoop", &BRNoop, + laige::fpx16_16::fromInt32(100)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + + Profiler prof(Profiler::Options{}); + FrameBudgetRecorder rec; + for (std::uint32_t i = 0; i < 5; ++i) { + w.beginFrame(); + ASSERT_TRUE(w.runSystems(sched).ok()); + prof.recordTick(0.01); + FrameBudgetRecord r; + r.frame = i; + r.tickAfter = i; + r.ticks = 1; + rec.recordFrame(r); + } + + const laige::BudgetTable budgets = loadTableOrDie(kNoAllocsBudgetsPath); + const FrameBudgetReport report = buildFrameBudgetReport(rec, prof, w, + budgets); + std::remove(kNoAllocsBudgetsPath); + + // The missing entry is a configuration error: a loud NO_ENTRY line, + // and the overall folds to FAIL (never green over a broken harness). + EXPECT_FALSE(report.passed); + EXPECT_TRUE( + contains(report.report, "budget=sim_heap_allocs result=NO_ENTRY")); + EXPECT_TRUE(contains(report.report, "laige-budget-report overall=FAIL")); + // The present entries still evaluate normally. + EXPECT_TRUE( + contains(report.report, + "budget=sim_tick_avg result=PASS metric=mean unit=ms")); +} + +TEST(BudgetReport, EmptyRecorderIsLoudNoSamples) { + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + World w = makeWorld(); + ASSERT_TRUE(w.registerSystem(makeDef("BRNoop", &BRNoop, + laige::fpx16_16::fromInt32(100)), + Io{}) + .ok()); + + // No completed ticks: the tick window, the per-frame alloc + // histogram, and the system's rolling window are all empty — three + // loud NO_SAMPLES states (two tick budgets, one alloc budget) plus + // the system's NO_SAMPLES line, and an empty frame section. + Profiler prof(Profiler::Options{}); + FrameBudgetRecorder rec; // fresh: zero records + const laige::BudgetTable budgets = loadTableOrDie(kHealthyBudgetsPath); + const FrameBudgetReport report = buildFrameBudgetReport(rec, prof, w, + budgets); + std::remove(kHealthyBudgetsPath); + + EXPECT_FALSE(report.passed); + EXPECT_TRUE(contains(report.report, "laige-budget-report frames: n=0 total=0")); + EXPECT_EQ(countLinesWith(report.report, "laige-budget-report frame="), 0u); + // Four loud NO_SAMPLES states: the two tick budgets, the per-frame + // alloc histogram, and the system's rolling window (never silent + // over an empty harness — CORE-008). + EXPECT_EQ(countOccurrences(report.report, "result=NO_SAMPLES"), 4u); + EXPECT_TRUE(contains(report.report, "laige-budget-report overall=FAIL")); +} + +// --------------------------------------------------------------------------- +// The Engine wiring: the cached per-run report (built before the +// shutdown, readable after it), the G-R5 fold into the per-frame +// records, and the start validation +// --------------------------------------------------------------------------- + +TEST(BudgetReport, EngineOverBudgetRunReport) { + MemorySink* mem = installCaptureSink(); + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + // The test component must be registered before the system that + // declares it (the M1-SYS-01 io_unregistered check — the + // system_timing makeWorld pattern). + ASSERT_TRUE(engine.world()->registerComponent().ok()); + ASSERT_TRUE(engine.world() + ->registerSystem(makeDef("EngBurn", &BRBurn, burnBudgetMs()), + Io{}) + .ok()); + // The report's per-frame section is bounded to the last 4 retained + // frames (the startBudgetReport lastNFrames bound). + ASSERT_TRUE(engine.startBudgetReport(kHealthyBudgetsPath, 4).ok()); + EXPECT_TRUE(engine.budgetReportRequested()); + + const std::uint64_t kTicks = 10; + brBurnMs = 2.0; + const Status st = engine.run_headless(kTicks, 1); + brBurnMs = 0.0; + ASSERT_TRUE(st.ok()); + std::remove(kHealthyBudgetsPath); + + // The report is CACHED: readable after the run's ordered shutdown + // (the world and the profiler are released — the profileStats() + // precedent; engine.profiler() is nullptr now). + EXPECT_EQ(engine.profiler(), nullptr); + const FrameBudgetReport& rep = engine.lastBudgetReport(); + EXPECT_FALSE(rep.passed); + EXPECT_TRUE(contains(rep.report, "laige-budget-report overall=FAIL")); + // The frame section is bounded to 4 of the run's frames; the run + // completed EXACTLY 10 ticks (the frame budget 1 contract). + EXPECT_TRUE(contains(rep.report, "frames: n=4 total=")); + // The per-frame invariant (preemption-tolerant): every retained + // frame that ran a tick ran the burn — one warn, one critical, + // FAIL — and its sim_allocs stayed 0 (the steady state). + const std::size_t frameLines = + countLinesWith(rep.report, "laige-budget-report frame="); + EXPECT_GE(frameLines, 4u); + for (const std::string& line : splitLines(rep.report)) { + if (line.rfind("laige-budget-report frame=", 0) != 0) continue; + const std::uint64_t ticks = uintField(line, "ticks"); + if (ticks == 1) { + // The frame ran the burn: one warn, one critical, FAIL. + EXPECT_EQ(uintField(line, "overrun_warns"), 1u); + EXPECT_EQ(uintField(line, "critical_errors"), 1u); + EXPECT_TRUE(contains(line, "result=FAIL")); + } + // The steady state: no sim allocations in any retained frame. + EXPECT_EQ(uintField(line, "sim_allocs"), 0u); + } + // The per-system section (the authoritative G-R5 counters — exact, + // both multipliers exceeded on the nominal floor) and the + // over-budget list. + const std::string sys = + lineWith(rep.report, "laige-budget-report system id=1 name=EngBurn "); + ASSERT_FALSE(sys.empty()); + EXPECT_TRUE(contains(sys, "runs=10")); + EXPECT_TRUE(contains(sys, "warns=10")); + EXPECT_TRUE(contains(sys, "errors=10")); + EXPECT_TRUE(contains(sys, "result=FAIL")); + EXPECT_GE(fieldOf(sys, "measured_p99_ms"), 2.0); + const std::string ob = + lineWith(rep.report, "laige-budget-report over_budget: id=1 "); + ASSERT_FALSE(ob.empty()); + EXPECT_TRUE(contains(ob, "name=EngBurn ")); + + // The G-R5 events themselves fired, every occurrence (the capture + // sink has rate limiting OFF): 10 warns, 10 criticals, subsystem + // "system". + EXPECT_EQ(countEvents(*mem, "budget_overrun"), 10u); + EXPECT_EQ(countEvents(*mem, "budget_critical"), 10u); + + // The machine-greppable summary (the ctest output). + std::printf("budget-report-engine ticks=%llu warns=10 errors=10 " + "overall=FAIL frames=%zu\n", + static_cast(kTicks), frameLines); + restoreLogger(); +} + +TEST(BudgetReport, EngineHealthyRunReportPasses) { + MemorySink* mem = installCaptureSink(); + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + ASSERT_TRUE(engine.world()->registerComponent().ok()); + // Budget 100 ms: the noop's microsecond ticks stay under it (the + // noise-floor precedent; a stretched tick would carry its own G-R5 + // event, which the zero-event assertions below reject). + ASSERT_TRUE(engine.world() + ->registerSystem(makeDef("EngNoop", &BRNoop, + laige::fpx16_16::fromInt32(100)), + Io{}) + .ok()); + ASSERT_TRUE(engine.startBudgetReport(kHealthyBudgetsPath).ok()); + const Status st = engine.run_headless(10, 1); + ASSERT_TRUE(st.ok()); + std::remove(kHealthyBudgetsPath); + + const FrameBudgetReport& rep = engine.lastBudgetReport(); + EXPECT_TRUE(rep.passed); + EXPECT_TRUE(contains(rep.report, "laige-budget-report overall=PASS")); + EXPECT_TRUE(contains(rep.report, "laige-budget-report over_budget: none")); + EXPECT_TRUE(contains( + rep.report, + "budget=sim_heap_allocs result=PASS metric=max unit=allocs_per_frame")); + // The healthy path is silent (LOG-003): no G-R5 event at all. + EXPECT_EQ(countEvents(*mem, "budget_overrun"), 0u); + EXPECT_EQ(countEvents(*mem, "budget_critical"), 0u); + restoreLogger(); +} + +TEST(BudgetReport, EngineZeroTickRunIsLoud) { + // A zero frame budget is rejected before the loop exists — the run + // still builds and caches the budget report (every run path; + // CORE-008: no silent omission): a zero-tick report, loud + // NO_SAMPLES lines included (the profile-report zero-tick + // precedent). + MemorySink* mem = installCaptureSink(); + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + ASSERT_TRUE(engine.world()->registerComponent().ok()); + ASSERT_TRUE(engine.world() + ->registerSystem(makeDef("EngNoop", &BRNoop, + laige::fpx16_16::fromInt32(100)), + Io{}) + .ok()); + ASSERT_TRUE(engine.startBudgetReport(kHealthyBudgetsPath).ok()); + const Status st = engine.run_headless(2, 0); + ASSERT_TRUE(st.isError()); + EXPECT_EQ(st.error(), ErrorCode::InvalidArgument); + std::remove(kHealthyBudgetsPath); + + const FrameBudgetReport& rep = engine.lastBudgetReport(); + EXPECT_FALSE(rep.passed); + EXPECT_TRUE(contains(rep.report, "laige-budget-report frames: n=0 total=0")); + EXPECT_TRUE(contains(rep.report, "laige-budget-report overall=FAIL")); + EXPECT_TRUE(contains(rep.report, "result=NO_SAMPLES")); + // No completed frame: no G-R5 event either (the silence is + // asserted, not assumed). + EXPECT_EQ(countEvents(*mem, "budget_overrun"), 0u); + EXPECT_EQ(countEvents(*mem, "budget_critical"), 0u); + restoreLogger(); +} + +TEST(BudgetReport, DoubleBudgetStartRejected) { + MemorySink* mem = installCaptureSink(); + ASSERT_TRUE(writeHealthyBudgets(kHealthyBudgetsPath)); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + ASSERT_TRUE(engine.startBudgetReport(kHealthyBudgetsPath).ok()); + const Status bad = engine.startBudgetReport(kHealthyBudgetsPath); + ASSERT_TRUE(bad.isError()); + EXPECT_EQ(bad.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*mem, "report_already_started"), 1u); + std::remove(kHealthyBudgetsPath); + engine.shutdown(); + restoreLogger(); +} + +TEST(BudgetReport, EmptyBudgetsPathRejected) { + MemorySink* mem = installCaptureSink(); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + const Status bad = engine.startBudgetReport(""); + ASSERT_TRUE(bad.isError()); + EXPECT_EQ(bad.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*mem, "report_path_invalid"), 1u); + engine.shutdown(); + restoreLogger(); +} + +TEST(BudgetReport, UnreadableBudgetsPathLoadFailed) { + MemorySink* mem = installCaptureSink(); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + const Status bad = + engine.startBudgetReport("/nonexistent-laige-dir/budgets.json"); + ASSERT_TRUE(bad.isError()); + EXPECT_EQ(bad.error(), ErrorCode::IoError); + EXPECT_EQ(countEvents(*mem, "report_load_failed"), 1u); + EXPECT_FALSE(engine.budgetReportRequested()); // not started + engine.shutdown(); + restoreLogger(); +} + +TEST(BudgetReport, MalformedBudgetsFileLoadFailed) { + MemorySink* mem = installCaptureSink(); + ASSERT_TRUE(writeBadVersionBudgets(kBadVersionBudgetsPath)); + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + const Status bad = engine.startBudgetReport(kBadVersionBudgetsPath); + ASSERT_TRUE(bad.isError()); + EXPECT_EQ(bad.error(), ErrorCode::MalformedInput); + EXPECT_EQ(countEvents(*mem, "report_load_failed"), 1u); + std::remove(kBadVersionBudgetsPath); + engine.shutdown(); + restoreLogger(); +} + +TEST(BudgetReport, StoppedEngineRejectsBudgetStart) { + laige::Engine engine = makeEngine(laige::EngineConfig{60, 128, 256}); + engine.shutdown(); + const Status bad = engine.startBudgetReport(kHealthyBudgetsPath); + ASSERT_TRUE(bad.isError()); + EXPECT_EQ(bad.error(), ErrorCode::InvalidArgument); +} diff --git a/tests/laige-sim/fixtures/budget_report_sample.txt b/tests/laige-sim/fixtures/budget_report_sample.txt new file mode 100644 index 0000000..7a9a2b4 --- /dev/null +++ b/tests/laige-sim/fixtures/budget_report_sample.txt @@ -0,0 +1,48 @@ +laige-budget-report version=1 +laige-budget-report context: workload=headless build= machine= warmup=0 +laige-budget-report frames: n=31 total=31 +laige-budget-report frame=0 ticks=0 tick_after=0 frame_ms=0.00018 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=1 ticks=1 tick_after=1 frame_ms=0.001954 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=2 ticks=1 tick_after=2 frame_ms=0.001343 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=3 ticks=1 tick_after=3 frame_ms=0.002235 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=4 ticks=1 tick_after=4 frame_ms=0.002866 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=5 ticks=1 tick_after=5 frame_ms=0.004138 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=6 ticks=1 tick_after=6 frame_ms=0.001824 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=7 ticks=1 tick_after=7 frame_ms=0.001323 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=8 ticks=1 tick_after=8 frame_ms=0.002024 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=9 ticks=1 tick_after=9 frame_ms=0.001553 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=10 ticks=1 tick_after=10 frame_ms=0.003887 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=11 ticks=1 tick_after=11 frame_ms=0.002525 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=12 ticks=1 tick_after=12 frame_ms=0.001483 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=13 ticks=1 tick_after=13 frame_ms=0.004479 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=14 ticks=1 tick_after=14 frame_ms=0.002474 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=15 ticks=1 tick_after=15 frame_ms=0.003126 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=16 ticks=1 tick_after=16 frame_ms=0.002314 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=17 ticks=1 tick_after=17 frame_ms=0.002114 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=18 ticks=1 tick_after=18 frame_ms=0.001743 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=19 ticks=1 tick_after=19 frame_ms=0.002615 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=20 ticks=1 tick_after=20 frame_ms=0.002695 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=21 ticks=1 tick_after=21 frame_ms=0.002084 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=22 ticks=1 tick_after=22 frame_ms=0.002194 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=23 ticks=1 tick_after=23 frame_ms=0.003917 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=24 ticks=1 tick_after=24 frame_ms=0.001924 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=25 ticks=1 tick_after=25 frame_ms=0.003888 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=26 ticks=1 tick_after=26 frame_ms=0.002996 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=27 ticks=1 tick_after=27 frame_ms=0.001923 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=28 ticks=1 tick_after=28 frame_ms=0.003056 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=29 ticks=1 tick_after=29 frame_ms=0.003497 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +laige-budget-report frame=30 ticks=1 tick_after=30 frame_ms=0.004329 sim_allocs=0 overrun_warns=0 critical_errors=0 result=PASS +budget=sim_tick_avg result=PASS metric=mean unit=ms + after=0.00148723 before=0 target=3 + stats: n=30 min=0.000712 mean=0.00148723 p50=0.001312 p95=0.002655 p99=0.002705 max=0.002705 + context: workload=headless build= machine= warmup=0 +budget=sim_tick_p99 result=PASS metric=p99 unit=ms + after=0.002705 before=0 target=5 + stats: n=30 min=0.000712 mean=0.00148723 p50=0.001312 p95=0.002655 p99=0.002705 max=0.002705 + context: workload=headless build= machine= warmup=0 +budget=sim_heap_allocs result=PASS metric=max unit=allocs_per_frame + after=0 before=0 target=0 + stats: n=31 min=0 mean=0 p50=0 p95=0 p99=0 max=0 + context: workload=headless build= machine= warmup=0 +laige-budget-report over_budget: none +laige-budget-report overall=PASS diff --git a/tools/run/CMakeLists.txt b/tools/run/CMakeLists.txt index d90ac72..af06399 100644 --- a/tools/run/CMakeLists.txt +++ b/tools/run/CMakeLists.txt @@ -35,9 +35,31 @@ add_test(NAME laige_run_smoke set_tests_properties(laige_run_smoke PROPERTIES TIMEOUT 300 PASS_REGULAR_EXPRESSION "status=ok") +# Budget-report smoke (M1-PROF-02, FR-11.2): a bounded 30-tick run +# with the opt-in budget report on (the AGENTS §12 field report to +# stdout) and the CI gate on (--fail-on-budget: a non-zero exit — +# 3 — when the report is overall=FAIL). The fixture config declares +# no systems (the 0-system smoke config — the report's system section +# is empty, over_budget: none) and the repo's budgets.json targets +# (3 ms tick mean / 5 ms tick p99 / 0 per-frame sim allocs) pass the +# ~microsecond ticks and the steady-state zero-alloc sim, so +# overall=PASS is expected: the test passes iff the report prints +# overall=PASS AND the run exits 0 (--fail-on-budget maps a FAIL +# report to exit 3, which ctest fails). The budgets path is passed +# EXPLICITLY (the ctest CWD is the build tree — the "budgets.json" +# working-directory fallback would not resolve there). +add_test(NAME laige_run_budget + COMMAND laige-run --headless + ${CMAKE_SOURCE_DIR}/tests/laige-sim/fixtures/headless_smoke.json + --ticks 30 --budget-report 4 + --budgets ${CMAKE_SOURCE_DIR}/budgets.json + --fail-on-budget) +set_tests_properties(laige_run_budget PROPERTIES TIMEOUT 120 + PASS_REGULAR_EXPRESSION "overall=PASS") + if(LAIGE_TSAN) # Same first-report-fatal policy as the other tool smoke tests # (NFR-8.2). - set_tests_properties(laige_run_smoke + set_tests_properties(laige_run_smoke laige_run_budget PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tools/run/laige-run.cpp b/tools/run/laige-run.cpp index 6255ec5..d13f424 100644 --- a/tools/run/laige-run.cpp +++ b/tools/run/laige-run.cpp @@ -10,7 +10,8 @@ // Usage (docs/api/engine.md, the "laige-run" section): // // laige-run --headless [--ticks N] [--replay ] -// [--prof-out ] +// [--prof-out ] [--budget-report [N]] +// [--budgets ] [--fail-on-budget] // // --headless run the engine headless with the given // JSON config (required; the windowed mode @@ -43,15 +44,39 @@ // state). A write failure does not fail // the run — it is reported on stderr and // the exit code becomes 2. +// --budget-report [N] BUDGET REPORT (M1-PROF-02, FR-11.2): +// print the last N frames' budget report +// to stdout at the end of the run (the +// AGENTS §12 field format: every declared +// budget — system time, total tick time, +// allocation count — measured vs declared +// with a pass/flag, plus the over-budget +// systems list). N: 1..kFrameBudgetWindow +// (32); omitted: all retained frames. +// EVERY build (diagnostics, not replay +// state). A budgets.json load failure +// exits 2 (the run did not happen). +// --budgets the budgets.json file (schema v1 — +// laige/budget_harness.h, the M0-CORE-08 +// table). Resolution: this arg, then the +// LAIGE_BUDGETS_PATH env var, then +// "budgets.json" in the working directory +// (the laige-bench resolution order). +// --fail-on-budget CI gate (PRD §8.1 budget policy): exit +// 3 when the run COMPLETED but the budget +// report is overall=FAIL. Without it the +// report is printed and the run exits 0. // // Exit codes (documented, stable for CI grepping): // 0 the run completed (the requested ticks reached; the summary // line carries the loop accounting) // 1 the engine run reported a failure Status (a failed frame — // the engine still shut down, CONC-006) -// 2 usage, IO, config-parse, engine-create, or profile-report -// write error (the message carries the NFR-13.3 5-field error -// text where one applies) +// 2 usage, IO, config-parse, engine-create, profile-report-write, +// or budget-report-start error (the message carries the +// NFR-13.3 5-field error text where one applies) +// 3 budget failure (--fail-on-budget: the run completed, the +// report is overall=FAIL) // // The one-line summary goes to stdout (machine-greppable, detcheck // precedent): @@ -74,6 +99,7 @@ #include #include +#include #include #include #include @@ -94,7 +120,8 @@ namespace { void printUsage(std::FILE* out) { std::fprintf(out, "Usage: laige-run --headless [--ticks N] " - "[--replay ] [--prof-out ]\n" + "[--replay ] [--prof-out ] [--budget-report [N]]\n" + " [--budgets ] [--fail-on-budget]\n" "\n" " --headless run the engine headless with the " "given\n" @@ -122,10 +149,34 @@ void printUsage(std::FILE* out) { " the end of the run (M1-PROF-01; EVERY\n" " build; a write failure exits 2 — the\n" " run itself completes)\n" + " --budget-report [N] print the last N frames' budget\n" + " report to stdout at the end of the\n" + " run (M1-PROF-02, FR-11.2 — the\n" + " AGENTS §12 field format: every\n" + " declared budget (system time, total\n" + " tick time, allocation count) measured\n" + " vs declared with a pass/flag, plus\n" + " the over-budget systems list). N: 1..32\n" + " (kFrameBudgetWindow); omitted: all\n" + " retained frames. The report needs\n" + " budgets.json — see --budgets\n" + " --budgets the budgets.json file (schema v1 —\n" + " docs/api/budget_harness.md); default:\n" + " the LAIGE_BUDGETS_PATH env var, then\n" + " \"budgets.json\" in the working\n" + " directory (the laige-bench\n" + " resolution order)\n" + " --fail-on-budget exit 3 when the run COMPLETED but\n" + " the budget report is overall=FAIL\n" + " (the CI gate — PRD §8.1 budget\n" + " policy); without it the report is\n" + " printed and the run exits 0\n" " --help, -h this help\n" "\n" "Exit codes: 0 = ok, 1 = engine run failure, 2 = usage / IO /\n" - "config / profile-report-write error.\n"); + "config / profile-report-write / budget-report-start error,\n" + "3 = budget failure (--fail-on-budget; the run completed, the\n" + "report is overall=FAIL).\n"); } // True when `text` parses as an unsigned 64-bit decimal integer @@ -143,6 +194,32 @@ bool parseTicks(std::string_view text, std::uint64_t* out) { return true; } +// Read an environment variable as a std::string (empty when unset). +// The laige-bench.cpp precedent (platform boundary, CPP-009): MSVC +// deprecates plain getenv (C4996, fatal under the engine's /WX +// policy, NFR-8.10), so the Windows branch uses the CRT's documented +// replacement, getenv_s, with the same lookup semantics. +#if defined(_MSC_VER) +// Largest environment value this tool reads (a budgets file path; +// far inside the bound). Named per CORE-005; a value beyond it is +// treated as unset (the documented fallback applies). MSVC-only: +// getenv_s needs a caller-sized buffer, so the constant has no use +// outside this branch (CORE-010: no unused symbols under -Werror). +constexpr std::size_t kEnvValueMaxBytes = 4096; + +std::string envValue(const char* name) { + char buf[kEnvValueMaxBytes]; + std::size_t len = 0; + if (getenv_s(&len, buf, sizeof(buf), name) != 0) return {}; + return std::string(buf, len); +} +#else +std::string envValue(const char* name) { + const char* v = std::getenv(name); + return (v != nullptr) ? std::string(v) : std::string(); +} +#endif + } // namespace int main(int argc, char** argv) { @@ -151,6 +228,10 @@ int main(int argc, char** argv) { std::uint64_t maxTicks = 0; std::string replayPath; std::string profOutPath; + bool budgetReport = false; + std::uint32_t budgetReportN = laige::kFrameBudgetWindow; + std::string budgetsPath; + bool failOnBudget = false; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; @@ -183,6 +264,28 @@ int main(int argc, char** argv) { return 2; } profOutPath = argv[++i]; + } else if (arg == "--budget-report") { + budgetReport = true; + // Optional N: a decimal in 1..kFrameBudgetWindow (the omitted + // form reports all retained frames — kFrameBudgetWindow). + if (i + 1 < argc) { + std::uint64_t n = 0; + if (parseTicks(argv[i + 1], &n) && n >= 1 && + n <= laige::kFrameBudgetWindow) { + budgetReportN = static_cast(n); + ++i; + } + } + } else if (arg == "--budgets") { + if (i + 1 >= argc) { + std::fprintf(stderr, "laige-run: --budgets needs a budgets.json " + "path\n"); + printUsage(stderr); + return 2; + } + budgetsPath = argv[++i]; + } else if (arg == "--fail-on-budget") { + failOnBudget = true; } else if (arg == "--help" || arg == "-h") { printUsage(stdout); return 0; @@ -246,6 +349,29 @@ int main(int argc, char** argv) { return 2; } } + // Budget report (M1-PROF-02, FR-11.2): opt-in, EVERY build. The + // budgets.json path resolves --budgets arg, then the + // LAIGE_BUDGETS_PATH env var, then "budgets.json" in the working + // directory (the laige-bench resolution order). The engine loads + // the table now (the cold setup path) and builds the report at the + // end of the run (engine.h "The frame graph / budget report"); a + // start failure is an exit-2 usage/IO error (the run did not + // happen). + if (budgetReport) { + std::string resolvedBudgetsPath = budgetsPath; + if (resolvedBudgetsPath.empty()) { + resolvedBudgetsPath = envValue("LAIGE_BUDGETS_PATH"); + } + if (resolvedBudgetsPath.empty()) resolvedBudgetsPath = "budgets.json"; + const laige::Status budgetStatus = + engine.startBudgetReport(resolvedBudgetsPath, budgetReportN); + if (budgetStatus.isError()) { + std::fprintf(stderr, "laige-run: budget-report: %s (path: %s)\n", + laige::errorText(budgetStatus.error()), + resolvedBudgetsPath.c_str()); + return 2; + } + } // The frame budget (the run_headless contract, engine.h): a bounded // run uses budget 1 — each frame runs AT MOST one tick, so the run // lands EXACTLY on maxTicks under any cadence (a late frame drops @@ -279,6 +405,22 @@ int main(int argc, char** argv) { std::fprintf(stdout, "%s\n", laige::formatProfileSummaryLine(engine.profileStats()) .c_str()); + // Budget report (M1-PROF-02, FR-11.2): the engine built it at the + // end of the run, BEFORE the shutdown (the world and the profiler + // were still live) and cached it (engine.h "The frame graph / + // budget report"). Printed on stdout after the profile line — the + // machine-greppable AGENTS §12 field format. Printed even on a + // failed run (the run failure dominates the exit code — the + // report's numbers describe what happened). + int budgetExit = 0; + if (budgetReport) { + const laige::FrameBudgetReport& report = engine.lastBudgetReport(); + std::fputs(report.report.c_str(), stdout); + // The CI gate (PRD §8.1 budget policy): the run COMPLETED and the + // report is overall=FAIL. A failed run exits 1 regardless (the + // gate never masks a run failure). + if (failOnBudget && !report.passed && runStatus.ok()) budgetExit = 3; + } // The run always ends in the ordered shutdown (CONC-006); this // second call exercises the idempotency (the M1-HEAD-01 test). engine.shutdown(); @@ -297,5 +439,5 @@ int main(int argc, char** argv) { laige::errorText(engine.profileReportStatus().error())); return 2; } - return 0; + return budgetExit; }