From 3af2e59545b5c9eea34a012d2877f7766d1470df Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Mon, 21 Sep 2026 00:10:59 +0200 Subject: [PATCH] [M1-DET-05] Replay diff: componentStateHash, World::stateDiff, diffReplays, laige-replay --diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-11.3 (diff two replays by frame/state). Two new public surfaces: - World::componentStateHash(tick): the state hash's canonical steps 1-4 (tick, live handles, archetype assignment, component bytes) with the PRNG substream state EXCLUDED — the diff's tick-alignment key (a different-seed replay diverges in substream state from tick 0 while its components may match for hundreds of ticks). - World::stateDiff(other, maxItems, fn): the bounded per-(entity, component) comparison in canonical order (slots ascending; presence item before component items; components ascending in the union of the two worlds' sets); exact total count, callback for the first maxItems; non-owning byte views into the worlds' columns. - diffReplays(logA, logB, worldA, worldB, configA, configB, maxEntries): identity check per log (both reported), determinism check, lock-step beginFrame/runSystems walk aligned on the component-state hash, honest fullStateDivergent flag from the full state hash, bounded state diff at the first divergent tick, length divergence, maxEntries 0/count-only, >64 clamped. CLI: laige-replay --diff --config [--entries N]: stable structured stdout report (replay_diff result=..., frames, first_divergent_tick, full_state_divergent, differing_items, reported_items, per-item slot/component/presence/hex lines), human summary + diagnostics on stderr, exit 0 identical / 1 divergent or length / 2 usage-identity-IO. Tests: ctest -R replay_diff (StateDiff.* + ReplayDiff.* suites — the bounded diff contract, the tick-37 integration scenario: two different-seed replays first diverging at tick 37 report tick 37 and the diverged component, the identity/determinism rejections, the draw-path KAT vs an independent Prng) + four laige-replay CLI entries (replay_diff_identical/length/identity/usage; template gains SETUP2). Docs: replay.md (diff section, report grammar, testing), entity.md (componentStateHash/stateDiff section, roadmap). laige-api.json regenerated. --- docs/api/entity.md | 74 ++ docs/api/replay.md | 194 +++- laige-api.json | 149 ++-- roadmap/M1-heartbeat.md | 2 +- src/laige-sim/CMakeLists.txt | 11 +- src/laige-sim/include/laige/sim/entity.h | 229 +++++ src/laige-sim/include/laige/sim/replay_diff.h | 308 +++++++ src/laige-sim/replay_diff.cpp | 181 ++++ src/laige-sim/state_hash.cpp | 53 +- tests/laige-sim/CMakeLists.txt | 44 +- tests/laige-sim/replay_diff_tests.cpp | 830 ++++++++++++++++++ tests/replay/CMakeLists.txt | 91 +- tests/replay/expect-replay-result.cmake.in | 15 + .../fixtures/replay_smoke_budget128.json | 6 + tools/replay/laige-replay.cpp | 402 +++++++-- 15 files changed, 2426 insertions(+), 163 deletions(-) create mode 100644 src/laige-sim/include/laige/sim/replay_diff.h create mode 100644 src/laige-sim/replay_diff.cpp create mode 100644 tests/laige-sim/replay_diff_tests.cpp create mode 100644 tests/replay/fixtures/replay_smoke_budget128.json diff --git a/docs/api/entity.md b/docs/api/entity.md index 57f0ca4..67e9232 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -292,6 +292,75 @@ known-answer vector, capacity independence, tick/handle sensitivity, component and archetype sensitivity, convergence equality, PRNG sensitivity, and the zero-allocation proof). +## The component-state hash and the state diff (`componentStateHash`, `stateDiff`, M1-DET-05) + +```cpp +std::uint64_t World::componentStateHash(std::uint64_t tick) const noexcept; + +struct StateDiffItem { + std::uint16_t slot; // the entity slot (the slot id, not the + // handle: slots are world-local) + std::uint32_t componentId; // 0 = an entity-presence difference + // (component 0 is reserved for it) + std::uint32_t size; // the component's sizeof (0 for a + // presence item) + bool presentInA, presentInB; // per side + const std::uint8_t* bytesA, *bytesB; // non-owning views into the + // world's component columns (null on + // the absent side; presence items: both + // null) +}; +template +[[nodiscard]] std::uint32_t stateDiff(const World& other, + std::uint32_t maxItems, + F&& fn) const noexcept; +``` + +- **`componentStateHash(tick)`** — the `stateHash` canonical stream + **minus the final step**: tick → live count → live `(slot, + generation)` pairs → per non-empty archetype `(ids, row count, raw + component bytes)`. The per-system PRNG substream state is excluded + ON PURPOSE: it is a pure function of (master seed, draws), and the + seed is a replay-identity field — two different-seed replays of the + same inputs diverge in substream state from tick 0 while their + components may match for hundreds of ticks. The replay DIFF + (`diffReplays`, [api/replay.md](replay.md)) aligns ticks on + `componentStateHash` so that the first reported divergence is the + first real *component* divergence, not tick 0; it still tracks the + full `stateHash` per tick and reports it honestly + (`ReplayDiffResult::fullStateDivergent`). Like `stateHash`, it is a + pure function of the live state, a cold path, `O(capacity + live + component bytes + kMaxArchetypes²)`, no allocation, `const`. +- **`stateDiff(other, maxItems, fn)`** — the bounded per-(entity, + component) comparison of two worlds' live state: + + - **Canonical order** — slots ascending; per slot, an + entity-presence item (`componentId == 0`) before the slot's + component items; components ascending in the UNION of the two + worlds' component sets (a component present on only one side is + reported for that slot — `presentInA`/`presentInB` + the absent + side's `bytes` null). Both-present components are compared by raw + bytes (a difference only when the bytes differ). + - **Bounded report, exact count** — `fn` is invoked for the first + `maxItems` differences; the return value is the TOTAL difference + count regardless of `maxItems` (`0` = count only). The callback's + argument is valid until the callback returns (the item's byte + views point into the worlds' columns and stay valid until the + next mutation of the involved entities — non-owning, PERF-005). + - **Precondition: identical component registries** — the same types + in the same registration order (the diff driver's replay-identity + check enforces it; a mismatch is a debug assert — CPP-012, + unreachable through `diffReplays`). + - **Performance (DOC-004):** `O(capacity + Σ live component bytes)` + (one two-pointer merge per live slot), the report bounded by + `maxItems`; no allocation, `const`, cold path. + +Verified by `ctest -R replay_diff` (the `StateDiff.*` + `ReplayDiff.*` +suites: the diff contract above, the tick-37 replay-diff integration +scenario, and the `diffReplays` driver — the identity/determinism +rejections, the lock-step walk, the bounded report at the first +divergence, the length divergence, and the draw-path KAT). + ## Usage (performant pattern) ```cpp @@ -360,3 +429,8 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } hash ("The deterministic state hash" section above); the replay execution half and the `laige-replay` runner live in [api/replay.md](replay.md); suite `ctest -R replay_replay`. +- **M1-DET-05 (done):** `World::componentStateHash` + `World::stateDiff` + ("The component-state hash and the state diff" section above) — the + replay diff's tick-alignment key and bounded comparison; the + `diffReplays` driver and `laige-replay --diff` live in + [api/replay.md](replay.md); suite `ctest -R replay_diff`. diff --git a/docs/api/replay.md b/docs/api/replay.md index 115ebf3..020d32e 100644 --- a/docs/api/replay.md +++ b/docs/api/replay.md @@ -1,15 +1,22 @@ -# Replay recording and execution (`ReplayRecorder`, `runReplay`, M1-DET-02/03) +# Replay recording, execution, and diff (`ReplayRecorder`, `runReplay`, `diffReplays`, M1-DET-02/03/05) -The M1 replay system in both halves: the **recording** (M1-DET-02; +The M1 replay system in its three halves: the **recording** (M1-DET-02; PRD FR-1.4, FR-11.3, PRD Appendix A — replay = input log + seed, ADR 0002, ARCH-007, SCALE-005) — a **versioned replay log format** plus the `ReplayRecorder` (the atomic, size-bounded writer), the `parseReplay`/`loadReplay` readers, and the replay-identity hashes — -and the **execution** (M1-DET-03): `World::stateHash` (the +the **execution** (M1-DET-03): `World::stateHash` (the deterministic state hash), `runReplay` (the identity-checked, tick-by-tick re-run that produces the per-tick hash stream), and the `laige-replay` runner that prints the stream and compares it against a -baseline. The engine records one frame per completed tick +baseline — and the **diff** (M1-DET-05; FR-11.3): `World::stateDiff` +(the bounded per-(entity, component) comparison), +`World::componentStateHash` (the state hash without the PRNG substream +state — the diff's tick-alignment key), `diffReplays` (replay two logs +in lock step, align by tick, report the first divergent tick + the +bounded state diff), and `laige-replay --diff --config +` — the editor replay viewer's (M5-ED-15) report source. The +engine records one frame per completed tick (`Engine::startReplayRecording`), `laige-run --replay ` wires the flag that M1-HEAD-01 stubbed, and `laige-replay --log --config [--expect ]` closes the loop. @@ -357,6 +364,157 @@ laige-replay --log LOG --config CONFIG.json [--expect BASELINE] `hello --log` wires this; M1-DET-04's detcheck `--run-a/--run-b` compares two scenarios' hash streams). +## The diff mode (`diffReplays`, `World::stateDiff`, M1-DET-05) + +```cpp +struct ReplayDiffResult { + bool identical; // every aligned tick matched (and equal + // frame counts) + std::uint64_t firstDivergentTick; // the first divergent completed + // tick (0 = the initial state; in the + // identical case: 0, a documented + // placeholder) + bool lengthDivergence; // the frame counts differ (no shared- + // tick divergence before the shorter log + // ends; firstDivergentTick = the first + // tick only one replay has) + bool fullStateDivergent; // the FULL state hashes (PRNG substream + // state included) differed on some + // aligned tick — the honest PRNG report + std::uint64_t framesA, framesB; + std::uint32_t differingItems; // the EXACT difference count at the + // first divergence (unbounded) + std::vector entries; // the BOUNDED report +}; +Result diffReplays(const ReplayLog& logA, + const ReplayLog& logB, World& worldA, World& worldB, + const EngineConfig& configA, const EngineConfig& configB, + std::uint32_t maxEntries = kReplayDiffDefaultEntries) noexcept; +``` + +Replay both logs **in lock step** on two caller-provided worlds and +report where they first differ (FR-11.3: "diff two replays by +frame/state"). The steps, in order: + +1. **Identity check, per log** — each log against THAT log's own + `(world, config)` (`replayIdentityDiff`, the M1-DET-03 check). Both + logs are checked before the error is returned, so one report names + every failing log (each with a `replay/diff_identity_mismatch` warn + listing the differing fields and both sides' values). A mismatch is + a **rejected diff** — `InvalidArgument`, never a silent divergence + (CORE-008, ADR 0002). +2. **Determinism check, per config** — a config with determinism + disabled is a rejected diff (`replay/diff_determinism_disabled` + warn, `InvalidArgument`) — the M1-DET-03 precedent. +3. **Lock-step tick walk** — `scheduleSystems` once per world, then one + `beginFrame(); runSystems();` per completed tick on BOTH worlds, + aligned over `min(framesA, framesB)` ticks (the recorded frame bytes + are opaque in M1 — no input system consumes them yet, M3-INPUT-03). +4. **Tick alignment on the component-state hash** — each aligned tick + compares `worldA.componentStateHash(tick)` against + `worldB.componentStateHash(tick)`: the canonical state-hash stream + (entity.md) **minus the per-system PRNG substream state**. The + substream state is a pure function of the (master seed, draws) — + the seed is a replay-identity field the diff legitimately varies + (two different-seed replays of the same inputs diverge in + substream state from tick 0 while their components may not differ + for hundreds of ticks), so aligning on the full `stateHash` would + report tick 0 and hide the first real component divergence. The + full `stateHash` is still compared at every aligned tick and feeds + the `fullStateDivergent` flag (never silent — CORE-008). +5. **Bounded state diff at the first divergence** — `worldA + .stateDiff(worldB, maxEntries, fn)` (entity.md) reports the exact + difference count and the first `maxEntries` differences in + canonical order (slots ascending; an entity-presence item — + `componentId == 0` — before the slot's component items; components + ascending in the union of the two worlds' sets). Both worlds sit at + the divergent tick's state (the walk stopped there), so the item + `bytesA`/`bytesB` views are valid until the next mutation of the + involved entities (non-owning, PERF-005). + +`maxEntries`: `0` = count only (`entries` empty, `differingItems` +exact); `1 .. kMaxReplayDiffEntries` (64) = the report bound; a value +above 64 is **clamped** to it (a report beyond 64 items is a full +dump — the bounded-report scope, CORE-003). The default is +`kReplayDiffDefaultEntries` (16). + +**Result field table:** + +| field | identical | divergent | length divergence | +|---|---|---|---| +| `identical` | `true` | `false` | `false` | +| `firstDivergentTick` | `0` (placeholder) | the first divergent completed tick | the first tick only one replay has (`alignedTicks + 1`) | +| `lengthDivergence` | `false` | `false` | `true` | +| `fullStateDivergent` | `false` | as observed (often `true` — different seeds) | as observed over the shared ticks | +| `differingItems` / `entries` | `0` / empty | exact / bounded (at the divergence tick) | `0` / empty (no shared state at that tick) | + +**Performance (PERF-002/003):** per tick, exactly two normal engine +ticks plus two state hashes and (on a matching tick) no diff work. +The state-diff pass runs ONCE, only at the first divergence: +`O(capacity + Σ component bytes)` with the report bounded by +`maxEntries` items; no allocation beyond the bounded `entries` +vector. Cold path (development tooling), never in the run hot path. + +**Misuse warnings:** + +- **Fresh, identically-registered worlds** — `worldA`/`worldB` must + carry the SAME component registrations (in the same order) as each + log's recording: the identity check enforces the schema hash, and + `stateDiff`'s registry match is a debug assert (CPP-012). The + worlds must start at the initial state the logs were recorded + from — the diff drives ticks from scratch, exactly as `runReplay`. +- **Scenario logs need the scenario binary** — a log recorded with + game-scenario registrations (e.g. `hello --replay`) is + schema-incompatible with this binary's built-in registrations, and + the identity check rejects it: diff scenario logs from the + scenario's own binary (the same constraint as M1-DET-03's + `--log`), or use `diffReplays` directly with matching worlds. +- **The entries' byte views die with the worlds** — read + `result.entries[*].bytesA/bytesB` before the worlds are destroyed or + the involved entities mutated (non-owning views, PERF-005). + +### `laige-replay --diff` (the CLI) + +``` +laige-replay --diff LOG_A LOG_B --config CONFIG.json [--entries N] +``` + +- **The per-log config** is `CONFIG.json` with THAT log's header seed + (`config.seed := log.identity.seed` for each side — the seed is the + identity field the diff legitimately varies; the other fields are + identity-checked and must match the recording). +- **stdout is the report only** — stable, machine-greppable grammar + (the editor replay viewer's source, M5-ED-15; CI fragments): + + ``` + replay_diff result= + frames_a= + frames_b= + first_divergent_tick= + length_divergence= + full_state_divergent= + differing_items= + reported_items= + item slot= component= presence= a= b= + ``` + + one `item` line per reported difference, in the canonical order; + `component 0` = an entity-presence difference; `a=`/`b=` are the + component bytes in lowercase hex, `-` where that side is absent. +- **stderr is human summary + diagnostics** (the identity-mismatch + report, the PRNG note when `full_state_divergent=true`, usage + errors) — never on stdout. +- **Exit codes:** `0` — identical; `1` — divergent (the first + divergence tick + the bounded diff are reported) or length- + divergent (the first tick only one replay has); `2` — usage error + (`--diff` needs two log paths; `--entries` needs an integer in + `0..64`; `--config` required; `--diff` mutually exclusive with + `--log`/`--expect`), config/log I/O error, identity mismatch, or a + failed diff run. +- **`--entries N`** — the report bound (library semantics: `0` = + count only, `>64` rejected at the CLI — the clamping is the library + API's job). + ## Performance (PERF-002/003, LOG-003) - **Disabled** — one null check per tick in the engine's onTick hook; @@ -418,20 +576,42 @@ laige-replay --log LOG --config CONFIG.json [--expect BASELINE] determinism-disabled rejection, and an engine round trip (record under `run_headless`, replay through two fresh engines, world-level twin state comparison). +- `ctest -R replay_diff` — the M1-DET-05 Verify (the `StateDiff.*` + + `ReplayDiff.*` suites in `tests/laige-sim/replay_diff_tests.cpp`): + the state-diff contract (identical worlds report nothing; component + byte differences with the canonical slot/component order; entity- + and component-presence items; the bounded report vs the exact + count), the component-state hash's PRNG-excluded scope, and the diff + driver — the **tick-37 integration scenario** (two different-seed + replays whose first component divergence lands at tick 37 report + tick 37 + the diverged component, the machine-greppable + `replay-diff-37` line), the tick-0 initial-state divergence, the + length divergence, the identical case, the identity-mismatch + rejection (both logs named, per field), the determinism-disabled + rejection, and the draw-path KAT against an independent + `laige::Prng`. - `ctest -R "^replay_"` — the `laige-replay` runner's CTest entries (`tests/replay/`, one generated check script per case): the smoke stream contract (N+1 lines, tick sequence, 16-hex hashes), the double-run determinism, `--expect` match / perturbed (divergence at the right tick) / truncated (length report) / malformed (line report) / identity mismatch (field report) / usage error / missing - log — exit codes 0/1/2 asserted per case. + log, and the diff mode — `replay_diff_identical` (two 16-tick + recordings, same fixture: `result=identical`, exit 0), + `replay_diff_length` (16 vs 10 ticks: `result=length_divergence`, + `first_divergent_tick=11`, exit 1), `replay_diff_identity` (a + budget-128 config against a budget-64 log: the `config_hash + DIFFERS` report, exit 2), and `replay_diff_usage` (`--diff` with one + log path, exit 2) — the stdout report grammar fragments asserted + per case. - `ctest -R fuzz_replay_parse` — the parser's malformed-input surface under the bounded-every-commit fuzz gate (1000 deterministic inputs; the corpus includes a valid v1 log as a mutate/truncate base; TEST-005, NFR-8.7). - The include-graph lint and the API manifest (`laige-api.json`) - cover the new public declarations (`stateHash`, `replayIdentityDiff`, - `runReplay`; regenerated in this change). + cover the new public declarations (`stateHash`, + `componentStateHash`, `stateDiff`, `replayIdentityDiff`, + `runReplay`, `diffReplays`; regenerated in this change). ## Related diff --git a/laige-api.json b/laige-api.json index bffcf91..b1c4e4e 100644 --- a/laige-api.json +++ b/laige-api.json @@ -21,6 +21,7 @@ "src/laige-sim/include/laige/sim/presentation.h", "src/laige-sim/include/laige/sim/query.h", "src/laige-sim/include/laige/sim/replay.h", + "src/laige-sim/include/laige/sim/replay_diff.h", "src/laige-sim/include/laige/sim/system.h" ], "symbols": [ @@ -461,69 +462,79 @@ {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 609, "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": 610, "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": 614, "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": 274, "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": 275, "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": 276, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 278, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 279, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 287, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 290, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 304, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, - {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 305, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 306, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 307, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 308, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 309, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 310, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 321, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 338, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 339, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 340, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 341, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 342, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 343, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 344, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 345, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 417, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, - {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 421, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 426, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 432, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, - {"name": "laige::World::Options::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 438, "signature": "std::uint64_t seed{0}", "summary": "The master simulation seed (M1-DET-01; PRD §10.3: the seed is part of the replay identity). Every system's PRNG substream is derived from (seed, system id) — the Prng::deriveSubstream contract (laige/prng.h). Default 0 — a valid master seed (the Prng's state is nonzero for every 64-bit seed, prng.h).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::deterministic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 446, "signature": "bool deterministic{true}", "summary": "Deterministic mode on/off (M1-DET-01; S-7: deterministic by default). When true, registerSystem derives each system's PRNG substream and SystemContext::rng names it; when false, the streams are not created and SystemContext::rng is nullptr (a system that draws must handle nullptr as \"no random source\"). See determinism.h \"Determinism mode semantics\" for the full M1 scope.", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 452, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 457, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, - {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 467, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, - {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 474, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, - {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 477, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 480, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, - {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 484, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, - {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 488, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 503, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, - {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 509, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 530, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 535, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 541, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, - {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 552, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, - {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 562, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, - {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 579, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, - {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 587, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, - {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 593, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 598, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 633, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, - {"name": "laige::World::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 674, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, - {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 679, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 686, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, - {"name": "laige::World::scheduleSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 708, "signature": "[[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept", "summary": "Compute and validate this world's execution order into `out` (SystemSchedule). Setup phase (after all registrations, before the loop); a pure read of the registry (const). The order is the stable topological sort of the registration order plus the declared depends_on edges (system.h). Validation order (first failure wins): unknown dependency name (system/dep_missing), dependency cycle (system/dependency_cycle), two systems writing the same component (system/double_writer) — each InvalidArgument + one rate-limited warn; a declared read ordered before a declared write of the same component WARNs without failing (system/read_before_write). Success: `out` fully populated, nothing logged (LOG-003). Setup path: O(n·d·n + c·n²) in the system count n (≤ kMaxSystems), direct dependencies d (≤ kMaxSystemDependencies), and component count c (≤ kMaxComponentTypes); no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::runSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 727, "signature": "[[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept", "summary": "Run the systems of `schedule` once — one sim tick's system phase (the M1-LOOP-01 accumulator calls this once per tick). The systems run strictly one at a time, in schedule order, on the world's single owner thread (PRD §10.2); each gets a fresh non-owning SystemContext. O(n) dispatch plus the systems' own work; no allocation (PERF-003), no logging on the success path (LOG-003).", "budget": null, "experimental": false}, - {"name": "laige::World::systemTimingStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 740, "signature": "[[nodiscard]] Result systemTimingStats(SystemId id) const noexcept", "summary": "The per-system timing snapshot (SystemTimingStats: the run count, the last measured ms, and the warn/error counts). O(1), no allocation, no side effects (a pure query, like system()). `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, - {"name": "laige::World::systemTimingWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 749, "signature": "[[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept", "summary": "The per-system rolling window (the M0-CORE-08 Histogram of the last kSystemTimingWindowSamples measured run times, ms). Cold path: the M1-PROF-02 frame graph's budgetCheck consumes it (its stats() is O(n log n)). nullptr for an invalid id or a moved-from world. The window is owned by the world (one owner thread — CONC-001): never keep the reference past the world.", "budget": null, "experimental": false}, - {"name": "laige::World::stateHash", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 769, "signature": "[[nodiscard]] std::uint64_t stateHash(std::uint64_t tick) const noexcept", "summary": "The deterministic 64-bit hash of the world's live sim state at `tick` completed ticks (the replay hash line, M1-DET-03): the tick, the live entity handles, the archetype assignment, every live component's bytes in the canonical order, and every system's PRNG substream state (preamble \"Canonical encoding\"). A pure function of the state — the free list, dead-slot generations, empty archetypes, presentation, timing, and guardrail state are EXCLUDED (preamble \"SCOPE\"). `tick` is the caller's completed-tick count (0 before the first tick — the initial state's hash). Cold path: O(capacity + live component bytes + kMaxArchetypes²); no allocation, no side effects, no logging. allocation.", "budget": "O(capacity + live component bytes + kMaxArchetypes²); no", "experimental": false}, - {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 782, "signature": "[[nodiscard]] Status clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 786, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 787, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 788, "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": 789, "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": 794, "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::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}, + {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 279, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 280, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 288, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 291, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 305, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, + {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 306, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 307, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 308, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 309, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 310, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 311, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 322, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 339, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 340, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 341, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 342, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 343, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 344, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 345, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 346, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 418, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, + {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 422, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 427, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 433, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, + {"name": "laige::World::Options::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 439, "signature": "std::uint64_t seed{0}", "summary": "The master simulation seed (M1-DET-01; PRD §10.3: the seed is part of the replay identity). Every system's PRNG substream is derived from (seed, system id) — the Prng::deriveSubstream contract (laige/prng.h). Default 0 — a valid master seed (the Prng's state is nonzero for every 64-bit seed, prng.h).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::deterministic", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 447, "signature": "bool deterministic{true}", "summary": "Deterministic mode on/off (M1-DET-01; S-7: deterministic by default). When true, registerSystem derives each system's PRNG substream and SystemContext::rng names it; when false, the streams are not created and SystemContext::rng is nullptr (a system that draws must handle nullptr as \"no random source\"). See determinism.h \"Determinism mode semantics\" for the full M1 scope.", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 453, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 458, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, + {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 468, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, + {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 475, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, + {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 478, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 481, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, + {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 485, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, + {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 489, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 504, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, + {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 510, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 532, "signature": "struct StateDiffItem", "summary": "One (entity, component) state difference between two worlds (World::stateDiff, M1-DET-05; the full contract in include/laige/sim/replay_diff.h and docs/api/replay.md). A plain value: the two byte pointers are NON-OWNING views into the worlds' component columns — valid until the next mutation of the involved entities' components (the get pointer contract, PERF-005). The item describes one difference only:", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::slot", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 535, "signature": "std::uint16_t slot{}", "summary": "The entity slot id (the handle's id half; the cross-world identity — generations are per-world state).", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::componentId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 538, "signature": "std::uint32_t componentId{}", "summary": "The component id (identical in both worlds — the registry-match precondition); 0 for an entity-presence difference.", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::size", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 540, "signature": "std::uint32_t size{}", "summary": "The component's byte size (0 for a presence difference).", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::presentInA", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 542, "signature": "bool presentInA{}", "summary": "The slot's entity is live in `this` world.", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::presentInB", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 544, "signature": "bool presentInB{}", "summary": "The slot's entity is live in `other` (the stateDiff argument).", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::bytesA", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 546, "signature": "const std::uint8_t* bytesA{}", "summary": "The component bytes in `this` world (nullptr when absent).", "budget": null, "experimental": false}, + {"name": "laige::World::StateDiffItem::bytesB", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 548, "signature": "const std::uint8_t* bytesB{}", "summary": "The component bytes in `other` (nullptr when absent).", "budget": null, "experimental": false}, + {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 570, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 575, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 581, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 592, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, + {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 602, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, + {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 619, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, + {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 627, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 633, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 638, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 673, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, + {"name": "laige::World::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 714, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, + {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 719, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 726, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, + {"name": "laige::World::scheduleSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 748, "signature": "[[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept", "summary": "Compute and validate this world's execution order into `out` (SystemSchedule). Setup phase (after all registrations, before the loop); a pure read of the registry (const). The order is the stable topological sort of the registration order plus the declared depends_on edges (system.h). Validation order (first failure wins): unknown dependency name (system/dep_missing), dependency cycle (system/dependency_cycle), two systems writing the same component (system/double_writer) — each InvalidArgument + one rate-limited warn; a declared read ordered before a declared write of the same component WARNs without failing (system/read_before_write). Success: `out` fully populated, nothing logged (LOG-003). Setup path: O(n·d·n + c·n²) in the system count n (≤ kMaxSystems), direct dependencies d (≤ kMaxSystemDependencies), and component count c (≤ kMaxComponentTypes); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::runSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 767, "signature": "[[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept", "summary": "Run the systems of `schedule` once — one sim tick's system phase (the M1-LOOP-01 accumulator calls this once per tick). The systems run strictly one at a time, in schedule order, on the world's single owner thread (PRD §10.2); each gets a fresh non-owning SystemContext. O(n) dispatch plus the systems' own work; no allocation (PERF-003), no logging on the success path (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::World::systemTimingStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 780, "signature": "[[nodiscard]] Result systemTimingStats(SystemId id) const noexcept", "summary": "The per-system timing snapshot (SystemTimingStats: the run count, the last measured ms, and the warn/error counts). O(1), no allocation, no side effects (a pure query, like system()). `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::systemTimingWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 789, "signature": "[[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept", "summary": "The per-system rolling window (the M0-CORE-08 Histogram of the last kSystemTimingWindowSamples measured run times, ms). Cold path: the M1-PROF-02 frame graph's budgetCheck consumes it (its stats() is O(n log n)). nullptr for an invalid id or a moved-from world. The window is owned by the world (one owner thread — CONC-001): never keep the reference past the world.", "budget": null, "experimental": false}, + {"name": "laige::World::stateHash", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 809, "signature": "[[nodiscard]] std::uint64_t stateHash(std::uint64_t tick) const noexcept", "summary": "The deterministic 64-bit hash of the world's live sim state at `tick` completed ticks (the replay hash line, M1-DET-03): the tick, the live entity handles, the archetype assignment, every live component's bytes in the canonical order, and every system's PRNG substream state (preamble \"Canonical encoding\"). A pure function of the state — the free list, dead-slot generations, empty archetypes, presentation, timing, and guardrail state are EXCLUDED (preamble \"SCOPE\"). `tick` is the caller's completed-tick count (0 before the first tick — the initial state's hash). Cold path: O(capacity + live component bytes + kMaxArchetypes²); no allocation, no side effects, no logging. allocation.", "budget": "O(capacity + live component bytes + kMaxArchetypes²); no", "experimental": false}, + {"name": "laige::World::componentStateHash", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 840, "signature": "[[nodiscard]] std::uint64_t componentStateHash(std::uint64_t tick) const noexcept", "summary": "The component-state part of the state hash: the canonical encoding's steps 1-4 (the tick, the live entity count and handles, the archetype assignment, and every live component's bytes in the canonical order — preamble \"Canonical encoding\") with the PRNG substream state (step 5) EXCLUDED. Same scope, complexity, and allocation contract as stateHash; a pure function of the state; no allocation, no side effects, no logging; `const`. COLD path — the replay diff driver (replay_diff.h diffReplays) calls it once per aligned tick; the engine's per-tick hot path never does.", "budget": "O(capacity + live component bytes + kMaxArchetypes²); no", "experimental": false}, + {"name": "laige::World::stateDiff", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 870, "signature": "template [[nodiscard]] std::uint32_t stateDiff(const World& other, std::uint32_t maxItems, F&& fn) const noexcept", "summary": "Compare this world's live state against `other`'s, invoking `fn(const StateDiffItem&)` once per difference in canonical order — live slots ascending, and per live slot: the presence item (when exactly one world has the entity live) before the component items, which walk the UNION of the two worlds' component sets in ascending component-id order — and returns the TOTAL number of differences (which may exceed `maxItems`: the callback is invoked for the first `maxItems` differences only — the bounded report, replay_diff.h).", "budget": "O(max(capacityA, capacityB) × (components per entity +", "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 885, "signature": "[[nodiscard]] Status clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 889, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 890, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"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::kMinTickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 253, "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": 256, "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": 258, "signature": "inline constexpr std::uint32_t kMaxTickRateHz = 120", "summary": null, "budget": null, "experimental": false}, @@ -633,6 +644,18 @@ {"name": "laige::ReplayRunResult", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 531, "signature": "struct ReplayRunResult", "summary": "The result of runReplay: the per-tick state hashes (the hash line contract above): tickHashes[i] == the world's stateHash(i) after the replay — index 0 is the initial state, indices 1..frameCount one entry per completed tick.", "budget": null, "experimental": false}, {"name": "laige::ReplayRunResult::tickHashes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 533, "signature": "std::vector tickHashes", "summary": "frameCount + 1 entries (tick 0 .. frameCount).", "budget": null, "experimental": false}, {"name": "laige::runReplay", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay.h", "line": 580, "signature": "[[nodiscard]] Result runReplay(const ReplayLog& log, World& world, const EngineConfig& config) noexcept", "summary": "Replay `log` headlessly on `world`:", "budget": "O(frameCount × tick work + state hash); one allocation", "experimental": false}, + {"name": "laige::kReplayDiffDefaultEntries", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 233, "signature": "inline constexpr std::uint32_t kReplayDiffDefaultEntries = 16", "summary": "The default bounded-report size (differences reported in ReplayDiffResult::entries when maxEntries is left at its default). 16 items names the divergence without dumping a 10k-entity world.", "budget": null, "experimental": false}, + {"name": "laige::kMaxReplayDiffEntries", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 239, "signature": "inline constexpr std::uint32_t kMaxReplayDiffEntries = 64", "summary": "The hard cap on the bounded report: a diff report beyond 64 differing (entity, component) pairs is a full dump, not a report — diffReplays clamps maxEntries to this, and the laige-replay CLI rejects --entries values above it at the argument boundary.", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult", "kind": "struct", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 250, "signature": "struct ReplayDiffResult", "summary": "The result of one diffReplays call (field table in the header preamble \"diffReplays contract\"). A plain value; the entries' byte pointers are non-owning views into the two worlds' component columns (World::StateDiffItem — valid until the next mutation of the involved entities' components).", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::identical", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 254, "signature": "bool identical{}", "summary": "True iff every aligned tick's component-state hash matched and the frame counts are equal (firstDivergentTick is 0 in that case — a meaningless placeholder).", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::firstDivergentTick", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 258, "signature": "std::uint64_t firstDivergentTick{}", "summary": "The first tick whose component-state hash differs (0 = the initial state); min(framesA, framesB) + 1 when lengthDivergence (the first tick only one replay has).", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::lengthDivergence", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 262, "signature": "bool lengthDivergence{}", "summary": "True when the frame counts differ and every shared tick matched (the reported tick has no state in the shorter replay — differingItems/entries are 0/empty in that case).", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::fullStateDivergent", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 267, "signature": "bool fullStateDivergent{}", "summary": "True when any compared tick's FULL state hash (stateHash steps 1-5, including the PRNG substream state) differed — the honest report for different-seed (or, from M3 on, different-input) replays whose component state is identical.", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::framesA", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 269, "signature": "std::uint64_t framesA{}", "summary": "The two logs' frame counts (the replayed completed-tick counts).", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::framesB", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 270, "signature": "std::uint64_t framesB{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::differingItems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 274, "signature": "std::uint32_t differingItems{}", "summary": "The TOTAL number of (entity, component) differences at firstDivergentTick (0 for identical/lengthDivergence); may exceed entries.size() (the bounded report).", "budget": null, "experimental": false}, + {"name": "laige::ReplayDiffResult::entries", "kind": "variable", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 278, "signature": "std::vector entries", "summary": "The first maxEntries differences, canonical order (World::stateDiff). Empty when maxEntries is 0 or nothing differed.", "budget": null, "experimental": false}, + {"name": "laige::diffReplays", "kind": "function", "header": "src/laige-sim/include/laige/sim/replay_diff.h", "line": 302, "signature": "[[nodiscard]] Result diffReplays(const ReplayLog& logA, const ReplayLog& logB, World& worldA, World& worldB, const EngineConfig& configA, const EngineConfig& configB, std::uint32_t maxEntries = kReplayDiffDefaultEntries) noexcept", "summary": "Replay log A on world A (config A) and log B on world B (config B) in lock step, aligned by tick; report the first divergent tick and the bounded state diff at that tick (full contract, error table, result fields, and performance notes in the header preamble above).", "budget": "O(aligned ticks × (per-tick system work + 2 × state hash) +", "experimental": false}, {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 393, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 394, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 399, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 0d15f0a..b486fde 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -203,7 +203,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** determinism CI job green on a merge across all P0 OS jobs (both backends); an intentional perturbation (a changed SimMath constant in a scratch system) makes the job fail (then revert). - **Size:** CI wiring + ~100 lines -- [ ] **M1-DET-05 · Replay diff tool** +- [x] **M1-DET-05 · Replay diff tool** - **Refs:** FR-11.3 (diff two replays by frame/state) - **Depends:** M1-DET-03 - **Scope:** diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 77f535c..b0e0d7e 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -65,10 +65,17 @@ # the live state; the public contract lives in include/laige/sim/ # entity.h) and the replay EXECUTION half in replay.cpp # (replayIdentityDiff + runReplay; the public types and contract live -# in include/laige/sim/replay.h). +# in include/laige/sim/replay.h). M1-DET-05 adds replay_diff.cpp: the +# replay DIFF (diffReplays — the identity-checked lock-step tick walk +# that aligns two replays on World::componentStateHash (state_hash.cpp, +# the canonical steps 1-4 shared with stateHash) and reports the first +# divergent tick plus the bounded state diff (World::stateDiff — +# entity.h); the public types and contract live in include/laige/sim/ +# replay_diff.h). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp system_timing.cpp game_loop.cpp - engine.cpp replay.cpp state_hash.cpp) + engine.cpp replay.cpp state_hash.cpp + replay_diff.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index bf00fc9..ba0f8e2 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -250,6 +250,7 @@ #pragma once +#include // stateDiff's registry-match precondition (CPP-012) #include #include #include @@ -508,6 +509,45 @@ class World { // allocation, no side effects. [[nodiscard]] GuardrailStats guardrailStats() const noexcept; + // One (entity, component) state difference between two worlds + // (World::stateDiff, M1-DET-05; the full contract in + // include/laige/sim/replay_diff.h and docs/api/replay.md). A plain + // value: the two byte pointers are NON-OWNING views into the + // worlds' component columns — valid until the next mutation of the + // involved entities' components (the get pointer contract, + // PERF-005). The item describes one difference only: + // + // componentId == 0 an entity-presence difference: the slot's + // entity is live in exactly one world + // (presentInA/presentInB say which; the size + // and byte fields are 0/null). + // componentId > 0, both present + // the component's bytes differ (same size — + // the registry-match precondition); both byte + // fields are valid. + // componentId > 0, one side only + // the component is attached in exactly one + // world; that side's byte field is valid, the + // other's is null. + struct StateDiffItem { + // The entity slot id (the handle's id half; the cross-world + // identity — generations are per-world state). + std::uint16_t slot{}; + // The component id (identical in both worlds — the registry-match + // precondition); 0 for an entity-presence difference. + std::uint32_t componentId{}; + // The component's byte size (0 for a presence difference). + std::uint32_t size{}; + // The slot's entity is live in `this` world. + bool presentInA{}; + // The slot's entity is live in `other` (the stateDiff argument). + bool presentInB{}; + // The component bytes in `this` world (nullptr when absent). + const std::uint8_t* bytesA{}; + // The component bytes in `other` (nullptr when absent). + const std::uint8_t* bytesB{}; + }; + // ----------------------------------------------------------------- // Component registry (M1-ECS-02; full contract in component.h) // ----------------------------------------------------------------- @@ -768,6 +808,69 @@ class World { // allocation. [[nodiscard]] std::uint64_t stateHash(std::uint64_t tick) const noexcept; + // ------------------------------------------------------------- + // Replay diff state access (M1-DET-05; full contract in + // include/laige/sim/replay_diff.h and docs/api/replay.md) + // ------------------------------------------------------------- + + // The component-state part of the state hash: the canonical + // encoding's steps 1-4 (the tick, the live entity count and + // handles, the archetype assignment, and every live component's + // bytes in the canonical order — preamble "Canonical encoding") + // with the PRNG substream state (step 5) EXCLUDED. Same scope, + // complexity, and allocation contract as stateHash; a pure + // function of the state; no allocation, no side effects, no + // logging; `const`. COLD path — the replay diff driver + // (replay_diff.h diffReplays) calls it once per aligned tick; the + // engine's per-tick hot path never does. + // + // WHY A SEPARATE HASH: the replay diff aligns two replays on THIS + // hash, not stateHash. The substream state is a pure function of + // the master seed (a replay-identity field a diff legitimately + // varies — different seeds, different input frames from M3 on) + // plus the draws so far: two different-seed replays diverge in the + // substream from tick 0 WITHOUT any component-state difference, so + // aligning on stateHash would report tick 0 for every different- + // seed diff and hide the first real component divergence (the + // FR-11.3 use case). The diff driver separately reports whether + // any compared tick's FULL state hash differed (the result's + // fullStateDivergent flag — never silent, CORE-008). + // @budget O(capacity + live component bytes + kMaxArchetypes²); no + // allocation. + [[nodiscard]] std::uint64_t componentStateHash(std::uint64_t tick) const noexcept; + + // Compare this world's live state against `other`'s, invoking + // `fn(const StateDiffItem&)` once per difference in canonical + // order — live slots ascending, and per live slot: the presence + // item (when exactly one world has the entity live) before the + // component items, which walk the UNION of the two worlds' + // component sets in ascending component-id order — and returns the + // TOTAL number of differences (which may exceed `maxItems`: the + // callback is invoked for the first `maxItems` differences only — + // the bounded report, replay_diff.h). + // + // PRECONDITION (the caller's invariant): the two worlds have + // IDENTICAL component registries (same count, same id order, same + // sizes) — diffReplays enforces it through the replay-identity + // check (replay_diff.h); a violation is a debug assert (CPP-012: + // a programmer invariant, not a recoverable runtime failure). + // + // Pure read: `const`, no allocation beyond the callback's own + // work, no logging, no side effects; it does NOT register with the + // M1-ECS-04 iteration-legality guard (the stateHash precedent — a + // cold-path const read, not a query). The callback must not + // re-enter World mutation or iteration on either world. + // + // Entity identity across worlds is the SLOT id (Entity handles are + // per-world — the entity.h caveat); generations and dead-slot + // state are not compared (stateHash scope). A slot beyond one + // world's capacity is "not present" in that world. + // @budget O(max(capacityA, capacityB) × (components per entity + + // component bytes)); cold path. + template + [[nodiscard]] std::uint32_t + stateDiff(const World& other, std::uint32_t maxItems, F&& fn) const noexcept; + // Destroy every live entity (shutdown path, CONC-006). Every handle // becomes stale; the capacity is unchanged and the world is // immediately reusable. O(capacity + detached rows * row-stride), @@ -943,6 +1046,17 @@ class World { // runSystems per system per tick (defined in system_timing.cpp). void checkSystemBudget(std::uint32_t id, double measuredMs) noexcept; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured run time never enters sim state, hashes, or replays (M1-SYS-03, ARCH-009) + // M1-DET-05: feed the canonical stream steps 1-4 (stateHash preamble + // "Canonical encoding": the tick, the live count, the live handles, + // the archetype-assigned component bytes) into a RUNNING FNV-1a 64 + // state (`h` is the state in and out — the state hash's whole state + // is one u64). Shared by stateHash (steps 1-4, then step 5's + // substream state) and componentStateHash (steps 1-4 only — the + // replay diff's tick alignment, replay_diff.h). Defined in + // state_hash.cpp next to both (the shared encoding is maintained in + // one place). + void feedStateSteps1to4(std::uint64_t& h, std::uint64_t tick) const noexcept; + // M1-ECS-04 query helpers: compile-time recursion over the listed // components (N ≤ 32 — the M1 bound). Recursion, not a fold: the // per-index component TYPE must reach a template argument, which a @@ -1117,6 +1231,121 @@ class World { std::unique_ptr systemTiming_; }; +// --------------------------------------------------------------------------- +// Replay diff state access (M1-DET-05). Header-defined: it is a +// template, so it must be visible to every translation unit that +// compares two worlds' state. Full contract in +// include/laige/sim/replay_diff.h and docs/api/replay.md. +// --------------------------------------------------------------------------- + +template +std::uint32_t +World::stateDiff(const World& other, std::uint32_t maxItems, F&& fn) const noexcept { + // The caller's invariant: identical component registries (the + // diff driver's replay-identity check enforces it — + // replay_diff.h). Debug assert; a violation is a programmer bug + // (CPP-012), unreachable through diffReplays. + const std::uint32_t count = componentCount_; + assert(other.componentCount_ == count && + "stateDiff requires identical component registries " + "(replay_diff.h)"); + for (std::uint32_t id = 1; id <= count; ++id) { + assert(components_[id - 1].size == other.components_[id - 1].size && + "stateDiff requires identical component sizes (replay_diff.h)"); + } + std::uint32_t total = 0; + std::uint32_t reported = 0; + // The union of the two slot spaces: a slot beyond a world's + // capacity is simply "not present" there. + const std::uint32_t capMax = + capacity_ > other.capacity_ ? capacity_ : other.capacity_; + // A sentinel id above every real id (kMaxComponentTypes): the + // two-pointer merge below compares (next A id, next B id). + constexpr std::uint32_t kSentinelId = kMaxComponentTypes + 1; + for (std::uint32_t slot = 0; slot < capMax; ++slot) { + const bool liveA = slot < capacity_ && alive_[slot] != 0; + const bool liveB = slot < other.capacity_ && other.alive_[slot] != 0; + if (liveA != liveB) { + // Entity-presence difference: the slot's entity is live in + // exactly one world (the componentId-0 item). + if (reported < maxItems) { + StateDiffItem item; + item.slot = static_cast(slot); + item.presentInA = liveA; + item.presentInB = liveB; + fn(item); + ++reported; + } + ++total; + continue; + } + if (!liveA) continue; // both dead (or both out of range) + // Both live: walk the UNION of the two component sets in + // ascending id order (both signatures are sorted ascending — + // archetype.h). The consumed signature index IS the column + // index (the columns sit in signature order). + const std::uint32_t archAId = archetypeOf_[slot]; + const std::uint32_t archBId = other.archetypeOf_[slot]; + const detail::ArchetypeRecord* recA = + archAId != 0 ? &archetypes_[archAId - 1] : nullptr; + const detail::ArchetypeRecord* recB = + archBId != 0 ? &other.archetypes_[archBId - 1] : nullptr; + const std::uint32_t sigCountA = recA != nullptr ? recA->sigCount : 0; + const std::uint32_t sigCountB = recB != nullptr ? recB->sigCount : 0; + const std::uint32_t rowA = rowOf_[slot]; + const std::uint32_t rowB = other.rowOf_[slot]; + std::uint32_t ia = 0; + std::uint32_t ib = 0; + while (ia < sigCountA || ib < sigCountB) { + const std::uint32_t idA = ia < sigCountA ? recA->sig[ia] : kSentinelId; + const std::uint32_t idB = ib < sigCountB ? recB->sig[ib] : kSentinelId; + const std::uint32_t id = idA < idB ? idA : idB; + const bool inA = idA == id; + const bool inB = idB == id; + if (inA) ++ia; + if (inB) ++ib; + StateDiffItem item; + item.slot = static_cast(slot); + item.componentId = id; + item.presentInA = inA; + item.presentInB = inB; + const detail::ArchetypeColumn* colA = + inA ? &recA->columns[ia - 1] : nullptr; + const detail::ArchetypeColumn* colB = + inB ? &recB->columns[ib - 1] : nullptr; + if (inA && inB) { + // Present in both: a difference only when the raw bytes + // differ (same size — the registry invariant above). + item.size = colA->size; + item.bytesA = reinterpret_cast( + colA->base + static_cast(rowA) * colA->size); + item.bytesB = reinterpret_cast( + colB->base + static_cast(rowB) * colB->size); + if (std::memcmp(item.bytesA, item.bytesB, item.size) == 0) { + continue; // identical bytes: not a difference + } + } else { + // Present in exactly one world: carry that side's bytes. + const detail::ArchetypeColumn& col = inA ? *colA : *colB; + item.size = col.size; + if (inA) { + item.bytesA = reinterpret_cast( + colA->base + static_cast(rowA) * colA->size); + } else { + item.bytesB = reinterpret_cast( + colB->base + static_cast(rowB) * colB->size); + } + } + if (reported < maxItems) { + fn(item); + ++reported; + } + ++total; + } + } + return total; +} + // Component registration (M1-ECS-02). Header-defined: it is a template, // so it must be visible to every translation unit that registers a // component. See component.h for the id contract, the type-identity diff --git a/src/laige-sim/include/laige/sim/replay_diff.h b/src/laige-sim/include/laige/sim/replay_diff.h new file mode 100644 index 0000000..7f893f4 --- /dev/null +++ b/src/laige-sim/include/laige/sim/replay_diff.h @@ -0,0 +1,308 @@ +// laige-sim replay diff (M1-DET-05). +// +// FR-11.3 (replay: diff two replays by frame/state), FR-1.4 +// (deterministic replays must be replayable and diffable), PRD +// Appendix A (replay = the input log + the seed), ADR 0002 (the +// replay identity — the two logs a diff compares may legitimately +// differ in seed and, from M3 on, in the recorded input frames), +// CORE-008 (a divergence is always reported — never silent), +// CORE-005 (the report bounds below are named constants), +// PERF-002/003 (cold path: a tool, never the engine's per-tick hot +// path). +// +// This header carries the FULL diff contract: +// +// StateDiffItem One (entity, component) state difference +// between two worlds (declared in entity.h, the +// World home — see there for the field table). +// ReplayDiffResult The result of one diff: the first divergent +// tick, the divergence kind, and the bounded +// state-difference report. +// diffReplays The diff driver: identity-check both logs, +// replay them in lock step on two worlds, align +// by tick, and report the first divergence plus +// the bounded state diff at that tick. +// +// The World-side state access the driver builds on: +// +// World::componentStateHash(tick) the component-state part of the +// state hash (entity.h steps 1-4: +// tick, live handles, archetype +// assignment, component bytes — the +// PRNG substream state EXCLUDED); +// the tick-alignment key. +// World::stateDiff(other, max, fn) the bounded per-(entity, +// component) comparison in +// canonical order (entity.h). +// +// --------------------------------------------------------------------------- +// Why the tick alignment uses componentStateHash, not stateHash +// --------------------------------------------------------------------------- +// +// Two replays being diffed typically differ in their master seed and, +// from M3 on, in their recorded input frames. The per-system PRNG +// substream state (stateHash step 5) is a pure function of (master +// seed, draws so far) — so two different-seed replays diverge in the +// substream state from tick 0 WITHOUT any component-state difference. +// Aligning the two replays on the FULL stateHash would therefore +// report tick 0 for every different-seed diff and hide the first +// real component divergence — the FR-11.3 use case (the editor replay +// viewer, M5-ED-15, asks "where did the two runs' worlds first +// differ?"). The diff aligns on World::componentStateHash instead, +// and reports the full-state divergence HONESTLY as a separate flag +// (ReplayDiffResult::fullStateDivergent — CORE-008: "the component +// state is identical" is never reported for two replays whose +// authoritative state diverged in the draw position). +// +// Scope statement (ARCH-010 style): the comparison is over the +// entity/component part of the authoritative state — the tick, the +// live entity handles, the archetype assignment, and every live +// component's bytes (stateHash steps 1-4; entity.h scope). The PRNG +// substream state is the one deliberately excluded piece; everything +// else stateHash excludes (dead-slot generations, the free list, +// empty archetypes, presentation, timing, guardrails) is excluded +// here too. +// +// --------------------------------------------------------------------------- +// diffReplays contract +// --------------------------------------------------------------------------- +// +// diffReplays(logA, logB, worldA, worldB, configA, configB, +// maxEntries = kReplayDiffDefaultEntries) +// +// The two worlds must be FRESH (initial state, nothing run yet) and +// registered IDENTICALLY (the caller's responsibility — the same +// registration order the recording runs used, ADR 0002/ARCH-010); +// configX is the EngineConfig of log X's recording run. A log is +// REPLAYED UNDER ITS OWN CONFIG: the caller derives configX from the +// base config by copying log X's header seed into configX.seed (the +// other config fields stay as passed) — the seed is the one identity +// field the diff legitimately varies, and configX must pass the full +// replay-identity check against (worldX, logX) below. +// +// Steps (first failure wins): +// +// 1. Identity check, per log: replayIdentityDiff(logX, worldX, +// configX) must be empty (the M1-DET-03 check, replay.h). A +// mismatch is a REJECTED DIFF — ErrorCode::InvalidArgument + one +// structured warn (replay/diff_identity_mismatch, naming the log +// "A"/"B" and exactly which fields differ). BOTH logs are +// checked before the error is returned (the report names every +// failing log, not just the first). The two logs may differ in +// seed and configHash (the diff inputs) but must share the +// component schema, tick rate, and math backend — both matching +// their (world, config) values enforces that. +// 2. Determinism check, per config: configX.determinism.enabled +// must be true (a log recorded with determinism disabled is not +// replayable — determinism.h). Failure: ErrorCode::InvalidArgument +// + one structured warn (replay/diff_determinism_disabled, +// naming the log). +// 3. Schedules: worldX.scheduleSystems once, before the loop (a +// failure returns the world's Status — already logged by the +// world; no partial result). +// 4. Tick walk, in lock step: +// - tick 0 (the initial state): compare componentStateHash(0) +// on both worlds; +// - for each completed tick t = 1 .. min(framesA, framesB): +// one beginFrame() + one runSystems(schedule) on EACH world +// (the M1-DET-03 frame discipline; the recorded frame bytes +// are opaque in M1 — no input system consumes them yet, +// M3-INPUT-03 — and are accepted and ignored), then compare +// componentStateHash(t). +// - the FIRST tick whose component-state hash differs is the +// divergence: the walk stops there and both worlds are left +// at that tick's state (the caller may inspect them — the +// stateDiff report below is taken at exactly that state). +// - every COMPARED tick (0 .. firstDivergentTick, or all +// aligned ticks when none differ) also has its FULL +// stateHash compared: fullStateDivergent records whether any +// of them differed (the honest PRNG-draw-position report). +// 5. State diff at the divergence (hash divergence only — not for +// a length divergence, where no shared state exists at the +// reported tick): worldA.stateDiff(worldB, maxEntries, ...) +// collects the bounded report (see below). +// 6. Result (below). +// +// A failed tick on either world returns that world's Status (the +// world already logged the failed system — CORE-008; no partial +// result). The worlds are NOT wound back: after a failure they hold +// the state of the last successful tick. +// +// Result (ReplayDiffResult): +// +// identical true iff every aligned tick's component- +// state hash matched AND the frame counts are +// equal (the replays' component states are +// identical end to end; firstDivergentTick is +// 0, a meaningless placeholder in that case) +// firstDivergentTick the first tick whose component-state hash +// differs (0 = the initial state already +// differs); when the frame counts differ but +// every shared tick matched, this is +// min(framesA, framesB) + 1 — the first tick +// only one replay has +// lengthDivergence true in the case above (the replays' frame +// counts differ; the component states match +// over the shared ticks — the reported tick +// has no state in the shorter replay, so +// differingItems/entries are 0/empty) +// fullStateDivergent true when any COMPARED tick's full stateHash +// (stateHash steps 1-5, including the PRNG +// substream state) differed — the two replays +// use different seeds (or, from M3 on, +// different inputs) even where the component +// state is identical +// framesA / framesB the two logs' frame counts (replayed ticks) +// differingItems the TOTAL number of (entity, component) +// differences at firstDivergentTick (0 for +// identical/lengthDivergence); may exceed +// entries.size() +// entries the first maxEntries differences (canonical +// order: slots ascending, presence items +// before component items, components ascending +// — World::stateDiff); each StateDiffItem's +// bytesA/bytesB point into worldA/worldB's +// component columns and stay valid until the +// next mutation of the involved entities +// (non-owning views, PERF-005) +// +// maxEntries: 0 = COUNT ONLY (differingItems is still exact; entries +// is empty); 1 .. kMaxReplayDiffEntries = the report bound; a value +// above kMaxReplayDiffEntries is CLAMPED to it (a report beyond 64 +// items is a full dump — the bounded-report scope). The default +// kReplayDiffDefaultEntries (16) is the CLI's default. +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003; cold path) +// --------------------------------------------------------------------------- +// +// One diff costs: two scheduleSystems (setup), then per aligned tick +// two beginFrame() + two runSystems (the sim work — the diff replays +// the sim, it does not add a second engine) plus two componentStateHash +// and two stateHash (each O(capacity + live bytes + +// kMaxArchetypes²), allocation-free), then one stateDiff at the +// divergence (O(capacity × components), bounded). The only allocation +// is the result's entries vector (≤ maxEntries items). Never on the +// engine's per-tick hot path; the laige-replay --diff CLI is the M1 +// consumer (the editor replay viewer, M5-ED-15, is the next one). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Pass FRESH, identically-registered worlds. A world that already +// ran ticks (or registered differently) makes the diff's +// component-state comparison meaningless — the replay-identity +// check catches a registration mismatch (the component schema +// hash), but not a pre-run world. +// - Derive configX's seed from log X's header (above). A config +// whose seed is not the log's seed fails the identity check's +// configHash field — the actionable report names it. +// - The entries' byte pointers are views: format or copy them out +// before mutating either world. +// - A log recorded with game-scenario registrations cannot be +// diffed by a tool that only registers the engine built-ins +// (the laige-replay binary's scope — the same constraint as +// M1-DET-03's --log mode): the component schema hash mismatch is +// a rejected diff. Scenario logs are diffed from the scenario's +// own binary, which registers the scenario's components. + +#pragma once + +#include +#include +#include + +#include "laige/errors.h" +#include "laige/result.h" +#include "laige/sim/entity.h" // World, World::StateDiffItem +#include "laige/sim/replay.h" // ReplayLog, ReplayIdentity, replayIdentityDiff + +namespace laige { + +struct EngineConfig; // declared in laige/sim/engine.h; only const + // references are used (configX.determinism.enabled) + +// ----------------------------------------------------------------------- +// Report bounds (CORE-005) +// ----------------------------------------------------------------------- + +// The default bounded-report size (differences reported in +// ReplayDiffResult::entries when maxEntries is left at its default). +// 16 items names the divergence without dumping a 10k-entity world. +inline constexpr std::uint32_t kReplayDiffDefaultEntries = 16; + +// The hard cap on the bounded report: a diff report beyond 64 +// differing (entity, component) pairs is a full dump, not a report — +// diffReplays clamps maxEntries to this, and the laige-replay CLI +// rejects --entries values above it at the argument boundary. +inline constexpr std::uint32_t kMaxReplayDiffEntries = 64; + +// ----------------------------------------------------------------------- +// The diff result +// ----------------------------------------------------------------------- + +// The result of one diffReplays call (field table in the header +// preamble "diffReplays contract"). A plain value; the entries' byte +// pointers are non-owning views into the two worlds' component +// columns (World::StateDiffItem — valid until the next mutation of +// the involved entities' components). +struct ReplayDiffResult { + // True iff every aligned tick's component-state hash matched and + // the frame counts are equal (firstDivergentTick is 0 in that + // case — a meaningless placeholder). + bool identical{}; + // The first tick whose component-state hash differs (0 = the + // initial state); min(framesA, framesB) + 1 when lengthDivergence + // (the first tick only one replay has). + std::uint64_t firstDivergentTick{}; + // True when the frame counts differ and every shared tick matched + // (the reported tick has no state in the shorter replay — + // differingItems/entries are 0/empty in that case). + bool lengthDivergence{}; + // True when any compared tick's FULL state hash (stateHash steps + // 1-5, including the PRNG substream state) differed — the honest + // report for different-seed (or, from M3 on, different-input) + // replays whose component state is identical. + bool fullStateDivergent{}; + // The two logs' frame counts (the replayed completed-tick counts). + std::uint64_t framesA{}; + std::uint64_t framesB{}; + // The TOTAL number of (entity, component) differences at + // firstDivergentTick (0 for identical/lengthDivergence); may + // exceed entries.size() (the bounded report). + std::uint32_t differingItems{}; + // The first maxEntries differences, canonical order + // (World::stateDiff). Empty when maxEntries is 0 or nothing + // differed. + std::vector entries; +}; + +// Replay log A on world A (config A) and log B on world B (config B) +// in lock step, aligned by tick; report the first divergent tick and +// the bounded state diff at that tick (full contract, error table, +// result fields, and performance notes in the header preamble above). +// +// both logs' identity vs (worldX, configX) -> InvalidArgument + one +// structured warn per +// failing log (replay/ +// diff_identity_mismatch) +// either config determinism disabled -> InvalidArgument + one +// structured warn (replay/ +// diff_determinism_disabled) +// a scheduleSystems failure -> the world's Status +// a tick's runSystems failure -> the world's Status +// success -> ReplayDiffResult +// +// Cold path (a tool, never the engine's per-tick hot path); see the +// header preamble "Performance". +// @budget O(aligned ticks × (per-tick system work + 2 × state hash) + +// the divergence state diff); one allocation (the entries vector, +// bounded by maxEntries). +[[nodiscard]] Result +diffReplays(const ReplayLog& logA, const ReplayLog& logB, + World& worldA, World& worldB, + const EngineConfig& configA, const EngineConfig& configB, + std::uint32_t maxEntries = kReplayDiffDefaultEntries) noexcept; + +} // namespace laige diff --git a/src/laige-sim/replay_diff.cpp b/src/laige-sim/replay_diff.cpp new file mode 100644 index 0000000..c6f3840 --- /dev/null +++ b/src/laige-sim/replay_diff.cpp @@ -0,0 +1,181 @@ +// laige-sim replay diff (M1-DET-05). +// +// Implementation of diffReplays declared in +// include/laige/sim/replay_diff.h — see that header for the FULL +// contract: why the tick alignment uses World::componentStateHash +// (the PRNG substream state excluded — it is a function of the master +// seed, a replay-identity field the diff legitimately varies) rather +// than World::stateHash, the diff steps, the result field table, the +// report bounds, the performance notes, and the misuse warnings. +// +// This file carries the tick walk: the identity + determinism checks +// (replay.h / determinism.h — the M1-DET-03 precedents), the +// lock-step beginFrame()/runSystems() drive (the runReplay frame +// discipline, replay.cpp), the per-tick component-state alignment +// (World::componentStateHash, state_hash.cpp), the honest full-state +// tracking (World::stateHash), and the bounded state-difference report +// at the first divergence (World::stateDiff, entity.h). + +#include "laige/sim/replay_diff.h" // the contract (this header) + +#include +#include +#include + +#include "laige/logging.h" // the structured replay/diff_* events +#include "laige/sim/engine.h" // EngineConfig (the determinism check) + +namespace laige { + +namespace { + +// Check log X's identity against (worldX, configX); when it mismatches, +// emit the structured warn naming the log and the differing fields and +// return true (the caller rejects the diff — CORE-008: a mismatch is a +// rejected diff, never a silent divergence, ADR 0002). +bool checkDiffIdentity(const char* logName, const ReplayIdentityDiff& diff) noexcept { + if (diff.empty()) return false; + LAIGE_LOG_WARN("replay", "diff_identity_mismatch", + "replay diff identity mismatch — the log was recorded " + "under a different identity; a mismatch is a rejected " + "diff, never a silent divergence (ADR 0002)", + laige::log::field("log", logName), + laige::log::field("seed", diff.seed), + laige::log::field("tick_rate", diff.tickRateHz), + laige::log::field("component_schema", diff.componentSchemaHash), + laige::log::field("math_backend", diff.mathBackendId), + laige::log::field("config", diff.configHash)); + return true; +} + +// The lock-step tick compare at completed tick `tick`: record whether +// the FULL state hashes differ (the honest PRNG report — the out +// parameter), and return whether the component-state hashes match +// (the alignment key — the return value). +bool compareTick(const World& a, const World& b, std::uint64_t tick, + bool& fullStateDivergent) noexcept { + fullStateDivergent |= (a.stateHash(tick) != b.stateHash(tick)); + return a.componentStateHash(tick) == b.componentStateHash(tick); +} + +} // namespace + +Result +diffReplays(const ReplayLog& logA, const ReplayLog& logB, + World& worldA, World& worldB, + const EngineConfig& configA, const EngineConfig& configB, + std::uint32_t maxEntries) noexcept { + // The bounded report: 0 = count only (no entries), above the hard + // cap = clamped to it (the bounded-report scope — the preamble). + const std::uint32_t reportBound = + (maxEntries == 0) ? 0 + : (maxEntries > kMaxReplayDiffEntries + ? kMaxReplayDiffEntries + : maxEntries); + + // Step 1: the identity check, per log — against THAT log's own + // (world, config) (the M1-DET-03 check, replay.h). BOTH logs are + // checked before the error is returned, so the report names every + // failing log, not just the first. + bool identityFailed = false; + identityFailed |= checkDiffIdentity("A", replayIdentityDiff(logA, worldA, configA)); + identityFailed |= checkDiffIdentity("B", replayIdentityDiff(logB, worldB, configB)); + if (identityFailed) { + return Result(ErrorCode::InvalidArgument); + } + + // Step 2: the determinism check, per config — a log recorded with + // determinism disabled is not replayable (determinism.h). + const char* nonDeterministicLog = + !configA.determinism.enabled ? "A" + : (!configB.determinism.enabled ? "B" : nullptr); + if (nonDeterministicLog != nullptr) { + LAIGE_LOG_WARN("replay", "diff_determinism_disabled", + "replay diff rejected — the config's determinism " + "mode is disabled; deterministic replays only " + "(the master seed and per-system PRNG substreams " + "are undefined with it off — determinism.h)", + laige::log::field("log", nonDeterministicLog), + laige::log::field("seed", nonDeterministicLog[0] == 'A' + ? configA.seed + : configB.seed)); + return Result(ErrorCode::InvalidArgument); + } + + // Step 3: the schedules, once per world, before the loop (a failure + // returns the world's Status — already logged by the world; no + // partial result, CORE-008). + SystemSchedule scheduleA; + const Status scheduleStatusA = worldA.scheduleSystems(scheduleA); + if (!scheduleStatusA.ok()) { + return Result(scheduleStatusA.error()); + } + SystemSchedule scheduleB; + const Status scheduleStatusB = worldB.scheduleSystems(scheduleB); + if (!scheduleStatusB.ok()) { + return Result(scheduleStatusB.error()); + } + + const std::uint64_t framesA = logA.frames.size(); + const std::uint64_t framesB = logB.frames.size(); + const std::uint64_t alignedTicks = framesA < framesB ? framesA : framesB; + + ReplayDiffResult result; + result.framesA = framesA; + result.framesB = framesB; + + // The divergence state: -1 = none yet, 0 = the initial state, or the + // first completed tick whose component-state hash differed. + long long divergedAt = -1; + + // Step 4: the tick walk, in lock step (the runReplay frame + // discipline, replay.cpp — one beginFrame() + one runSystems per + // completed tick on each world; the recorded frame bytes are + // opaque in M1 — no input system consumes them yet, M3-INPUT-03 — + // and are accepted and ignored). + if (!compareTick(worldA, worldB, 0, result.fullStateDivergent)) { + divergedAt = 0; + } + for (std::uint64_t tick = 1; + divergedAt < 0 && tick <= alignedTicks; ++tick) { + worldA.beginFrame(); + worldB.beginFrame(); + const Status statusA = worldA.runSystems(scheduleA); + if (!statusA.ok()) { + // The world already logged the failed system; the diff stops — + // no partial result (CORE-008). The worlds hold the state of + // the last successful tick. + return Result(statusA.error()); + } + const Status statusB = worldB.runSystems(scheduleB); + if (!statusB.ok()) { + return Result(statusB.error()); + } + if (!compareTick(worldA, worldB, tick, result.fullStateDivergent)) { + divergedAt = static_cast(tick); + } + } + + if (divergedAt >= 0) { + // Step 5: the bounded state diff at the first divergence — both + // worlds sit at that tick's state (the walk stopped there). + result.firstDivergentTick = static_cast(divergedAt); + result.entries.reserve(reportBound); + result.differingItems = worldA.stateDiff(worldB, reportBound, + [&result](const World::StateDiffItem& item) { + result.entries.push_back(item); + }); + } else if (framesA != framesB) { + // No shared-tick divergence, but the frame counts differ: the + // first tick only one replay has is alignedTicks + 1 (there is + // no shared state at that tick — the report is empty). + result.lengthDivergence = true; + result.firstDivergentTick = alignedTicks + 1; + } else { + result.identical = true; + result.firstDivergentTick = 0; // the documented placeholder + } + return Result::success(std::move(result)); +} + +} // namespace laige diff --git a/src/laige-sim/state_hash.cpp b/src/laige-sim/state_hash.cpp index 9a4d609..19574fd 100644 --- a/src/laige-sim/state_hash.cpp +++ b/src/laige-sim/state_hash.cpp @@ -64,18 +64,27 @@ int compareSig(const std::uint32_t* a, std::uint16_t countA, } // namespace -std::uint64_t World::stateHash(std::uint64_t tick) const noexcept { - Fnv1a64 h; +// The canonical stream steps 1-4 (entity.h "Canonical encoding"): the +// tick, the live count, the live handles, and the archetype-assigned +// component bytes — shared by stateHash (steps 1-4, then step 5's +// PRNG substream state) and componentStateHash (steps 1-4 only — the +// replay diff's tick alignment, replay_diff.h). One shared +// implementation keeps the canonical encoding maintained in exactly +// one place (CORE-006). The FNV state is a single u64, so the helper +// takes it in and out by reference. +void World::feedStateSteps1to4(std::uint64_t& h, std::uint64_t tick) const noexcept { // Step 1: the completed tick count. - h.word(tick); + Fnv1a64 stream; + stream.h = h; + stream.word(tick); // Step 2: the live entity count. - h.word(inUse_); + stream.word(inUse_); // Step 3: the live handles, ascending slot order (dead slots and // their generations are history — entity.h scope). for (std::uint32_t s = 0; s < capacity_; ++s) { if (alive_[s] != 0) { - h.word(static_cast(s)); - h.word(static_cast(generations_[s])); + stream.word(static_cast(s)); + stream.word(static_cast(generations_[s])); } } // Step 4: per distinct live component set (every NON-EMPTY @@ -105,23 +114,31 @@ std::uint64_t World::stateHash(std::uint64_t tick) const noexcept { } for (std::uint32_t i = 0; i < nonEmpty; ++i) { const detail::ArchetypeRecord& arch = archetypes_[order[i]]; - h.word(arch.sigCount); + stream.word(arch.sigCount); for (std::uint16_t c = 0; c < arch.sigCount; ++c) { - h.word(arch.sig[c]); + stream.word(arch.sig[c]); } - h.word(arch.size); + stream.word(arch.size); // The raw component bytes: rows in ascending slot order (the // dense-id row order, archetype.h invariant I2), columns in // signature order. for (std::uint32_t row = 0; row < arch.size; ++row) { for (std::uint16_t c = 0; c < arch.sigCount; ++c) { const detail::ArchetypeColumn& col = arch.columns[c]; - h.bytes(reinterpret_cast( - col.base + static_cast(row) * col.size), - col.size); + stream.bytes(reinterpret_cast( + col.base + static_cast(row) * col.size), + col.size); } } } + h = stream.h; +} + +std::uint64_t World::stateHash(std::uint64_t tick) const noexcept { + Fnv1a64 h; + // Steps 1-4 (shared with componentStateHash — see + // feedStateSteps1to4 above). + feedStateSteps1to4(h.h, tick); // Step 5: the per-system PRNG state, ascending system id (the draw // position is the replay state — PRD §10.3; systems without a // substream hash their absence, not nothing). @@ -140,4 +157,16 @@ std::uint64_t World::stateHash(std::uint64_t tick) const noexcept { return h.h; } +// The component-state part of the state hash (steps 1-4 only): the +// replay diff's tick alignment (replay_diff.h — the PRNG substream +// state is excluded: it is a function of the master seed, a replay- +// identity field the diff legitimately varies, and a different-seed +// replay would otherwise diverge at tick 0 without any component- +// state difference). +std::uint64_t World::componentStateHash(std::uint64_t tick) const noexcept { + std::uint64_t h = kFnvOffsetBasis; + feedStateSteps1to4(h, tick); + return h; +} + } // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 754ec01..715e766 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -25,7 +25,13 @@ # states — its pure-function-of-state property over convergent # worlds, and runReplay's 500-tick record->replay round trip, the # first-divergence detection, the identity-mismatch rejection, and -# the determinism-disabled rejection). +# the determinism-disabled rejection), and the replay diff +# (M1-DET-05: World::stateDiff's bounded canonical-order comparison, +# World::componentStateHash's PRNG-excluded scope, and diffReplays' +# identity/determinism rejections, the lock-step tick walk aligned on +# the component-state hash, the first divergent tick + bounded state +# diff, the length divergence, and the fullStateDivergent flag — +# including the tick-37 integration scenario). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus @@ -33,17 +39,19 @@ # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, # `system_timing`, `game_loop`, `presentation`, `engine`, -# `determinism_mode`, `replay_record`, and `replay_replay` entries +# `determinism_mode`, `replay_record`, `replay_replay`, and +# `replay_diff` 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-DET-01, M1-DET-02, and M1-DET-03 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 determinism_mode`, `ctest -R replay_record`, and -# `ctest -R replay_replay`), selecting exactly the suites below from +# M1-LOOP-02, M1-HEAD-01, M1-DET-01, M1-DET-02, M1-DET-03, and +# M1-DET-05 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 determinism_mode`, +# `ctest -R replay_record`, `ctest -R replay_replay`, and +# `ctest -R replay_diff`), selecting exactly the suites below from # the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp @@ -58,7 +66,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp engine_tests.cpp determinism_tests.cpp replay_record_tests.cpp - replay_replay_tests.cpp) + replay_replay_tests.cpp + replay_diff_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, @@ -242,6 +251,17 @@ add_test(NAME replay_replay COMMAND laige-sim_tests --gtest_filter=StateHash.*:DetReplay.*) +# M1-DET-05: the replay diff (FR-11.3: diff two replays by frame/ +# state). The step's Verify command is `ctest -R replay_diff`; this +# entry selects exactly the StateDiff / ReplayDiff suites from the +# shared laige-sim_tests executable (NOT Replay*-prefixed, so the +# replay_record entry above keeps selecting exactly the recording +# suites). The machine-greppable replay-diff-37 line lands in the +# ctest output (docs/testing.md §4). +add_test(NAME replay_diff + COMMAND laige-sim_tests + --gtest_filter=StateDiff.*:ReplayDiff.*) + # 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 @@ -305,6 +325,6 @@ if(LAIGE_TSAN) set_tests_properties(laige-sim_tests entity component_registry archetype query iter_order ecs_guardrails ecs_stress system_registry scheduler system_timing game_loop presentation engine determinism_mode - replay_record replay_replay PROPERTIES + replay_record replay_replay replay_diff PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/replay_diff_tests.cpp b/tests/laige-sim/replay_diff_tests.cpp new file mode 100644 index 0000000..eebda77 --- /dev/null +++ b/tests/laige-sim/replay_diff_tests.cpp @@ -0,0 +1,830 @@ +// laige-sim replay diff suite (M1-DET-05). +// +// Step Verify scope (roadmap/M1-heartbeat.md, `ctest -R replay_diff`): +// - `World::stateDiff` (the bounded per-(entity, component) +// comparison: canonical order — slots ascending, presence items +// before component items, components ascending in the union of the +// two worlds' sets — the exact difference set, and the bounded +// report vs the total count) +// - `World::componentStateHash` (the component-state part of the +// state hash — canonical steps 1-4, the PRNG substream state +// excluded; a pure function of the state like stateHash) +// - `laige::diffReplays` (the diff driver): the identity + +// determinism rejections (structured warns, never silent), the +// lock-step tick walk aligned on the component-state hash, the +// first divergent tick, the bounded state diff at that tick, the +// length divergence, and the honest fullStateDivergent flag — +// plus the roadmap's integration test: two replays that first +// diverge at tick 37 report tick 37 and the diverged component +// +// Suite names: StateDiff.* and ReplayDiff.* — deliberately NOT +// Replay*-prefixed (the replay_record CTest entry selects exactly the +// Replay* suites from this shared executable). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/result.h" +#include "laige/sim/engine.h" +#include "laige/sim/replay.h" +#include "laige/sim/replay_diff.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, + "replay_diff_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "replay_diff_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, + "replay_diff_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// The test component + the deferred-draw scenario system +// --------------------------------------------------------------------------- + +struct RdValue { + laige::fpx16_16 v{}; +}; +LAIGE_COMPONENT(RdValue); +LAIGE_DETERMINISM_SAFE(RdValue, laige::fpx16_16); + +// The scenario's per-entity completed-tick count (the scenario +// clock). Per-entity, NOT a shared global: the diff driver runs two +// worlds in lock step, and a shared counter would double-advance +// (one bump per world per tick pair). +struct RdTick { + laige::fpx16_16 t{}; +}; +LAIGE_COMPONENT(RdTick); +LAIGE_DETERMINISM_SAFE(RdTick, laige::fpx16_16); + +namespace { + +// Test plumbing for the deferred-draw scenario (the owner thread +// writes it only — PRD §10.2). +std::uint64_t gRdDrawFromTick = 0; // 0 = never draw + +} // namespace + +// The deferred draw: bumps every entity's per-entity tick count +// (RdTick), and — when armed (gRdDrawFromTick != 0) and an entity's +// completed tick count has REACHED it — draws once per entity from +// the system's PRNG substream (in the deterministic row order — the +// call order is the replay state, PRD §10.3) and writes the drawn +// value (as an integer fixed point) to the entity's RdValue. Before +// the arm tick the system only bumps the counters (identical state +// increments across seeds) — that is what makes the component state +// identical across seeds until the arm tick, and the first component +// divergence land exactly at it (the roadmap's tick-37 integration +// scenario). +LAIGE_SYSTEM(RdDraw, 1) +void RdDraw(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx.each( + [&ctx](laige::Entity e, RdValue& val, RdTick& tick) { + static_cast(e); + // The Q16.16 raw, exact integer ticks (no fraction): raw >> 16. + const std::uint32_t completed = + static_cast(tick.t.raw >> 16) + 1; + tick.t = laige::fpx16_16::fromInt32( + static_cast(completed)); + if (gRdDrawFromTick != 0 && completed >= gRdDrawFromTick) { + const std::uint32_t value = + ctx.rng != nullptr ? ctx.rng->next_range(0, 1000) : 0; + val.v = laige::fpx16_16::fromInt32(static_cast(value)); + } + }, + laige::Write{}, laige::Write{})); +} + +namespace { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +laige::World makeWorld(std::uint32_t capacity, std::uint64_t seed, + bool deterministic = true) { + laige::World::Options opts; + opts.capacity = capacity; + opts.seed = seed; + opts.deterministic = deterministic; + laige::Result r = + laige::World::create(opts); + if (!r.ok()) { + ADD_FAILURE() << "World::create failed"; + abort(); + } + return std::move(r).takeValue(); +} + +// The fixture scenario: capacity 64, RdValue (id 1) and RdTick +// (id 2) registered, RdDraw (system id 1) registered, and two +// entities with RdValue(0) + RdTick(0) — the fixed initial state both +// replays share (slot assignment is LIFO from the top: e1 lands in +// slot capacity-1, e2 in capacity-2). +struct RdScenario { + laige::World world; + laige::Entity e1{}; + laige::Entity e2{}; +}; + +RdScenario makeRdWorld(std::uint64_t seed, bool deterministic = true) { + laige::World world = makeWorld(64, seed, deterministic); + if (!world.registerComponent().ok() || + !world.registerComponent().ok()) { + ADD_FAILURE() << "component registration failed"; + abort(); + } + if (!world.registerSystem( + RdDraw_Def, + laige::Io{}, + laige::Io{}).ok()) { + ADD_FAILURE() << "RdDraw registration failed"; + abort(); + } + const laige::Result e1 = world.create(); + const laige::Result e2 = world.create(); + if (!e1.ok() || !e2.ok()) { + ADD_FAILURE() << "entity creation failed"; + abort(); + } + static_cast(world.addComponent(e1.value(), RdValue{ + laige::fpx16_16::fromInt32(0)})); + static_cast(world.addComponent(e1.value(), RdTick{ + laige::fpx16_16::fromInt32(0)})); + static_cast(world.addComponent(e2.value(), RdValue{ + laige::fpx16_16::fromInt32(0)})); + static_cast(world.addComponent(e2.value(), RdTick{ + laige::fpx16_16::fromInt32(0)})); + return RdScenario{std::move(world), e1.value(), e2.value()}; +} + +// The shared test config (the scenario's identity). +laige::EngineConfig makeConfig(std::uint64_t seed) { + laige::EngineConfig config; + config.tickRateHz = 60; + config.entityCapacity = 64; + config.churnPerFrameBudget = 256; + config.seed = seed; + return config; +} + +// Runs `ticks` ticks on `world` (one beginFrame() + one runSystems per +// tick — the replay driver's frame discipline) and returns the +// per-tick component-state hashes (tick 0 first). +std::vector +runTicksComponentState(laige::World& world, std::uint64_t ticks) { + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) { + ADD_FAILURE() << "scheduleSystems failed"; + abort(); + } + std::vector hashes; + hashes.reserve(ticks + 1); + hashes.push_back(world.componentStateHash(0)); + for (std::uint64_t t = 1; t <= ticks; ++t) { + world.beginFrame(); + if (!world.runSystems(sched).ok()) { + ADD_FAILURE() << "runSystems failed at tick " << t; + abort(); + } + hashes.push_back(world.componentStateHash(t)); + } + return hashes; +} + +// Records `ticks` ticks of `world` to a replay log at `path` (one +// zero-length frame per completed tick, the M1-DET-02 shape). +struct RecordResult { + std::string path; +}; + +RecordResult recordRun(laige::World& world, const laige::EngineConfig& config, + std::uint64_t ticks, const std::string& path) { + const laige::ReplayIdentity identity = + laige::makeReplayIdentity(world, config); + laige::Result recR = + laige::ReplayRecorder::create(identity, path, 0); + if (!recR.ok()) { + ADD_FAILURE() << "ReplayRecorder::create failed"; + abort(); + } + laige::ReplayRecorder rec = std::move(recR).takeValue(); + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) { + ADD_FAILURE() << "scheduleSystems failed"; + abort(); + } + for (std::uint64_t t = 1; t <= ticks; ++t) { + world.beginFrame(); + if (!world.runSystems(sched).ok()) { + ADD_FAILURE() << "runSystems failed at tick " << t; + abort(); + } + const laige::Status w = rec.writeFrame(t, nullptr, 0); + if (w.isError()) { + ADD_FAILURE() << "writeFrame failed at tick " << t; + abort(); + } + } + if (!rec.finish().ok()) { + ADD_FAILURE() << "ReplayRecorder::finish failed"; + abort(); + } + return RecordResult{path}; +} + +// A temp path under gtest's temp dir (the replay_record_tests pattern). +std::string tempPath(const char* name) { + return std::string(::testing::TempDir()) + name; +} + +// The fpx16_16 stored in a StateDiffItem byte view, decoded as its +// integer value (every P0 target is little-endian — PRD §6). The +// fpx16_16 stores a Q16.16 raw (value = raw / 2^16, raw in an +// int32_t); the test values are integers, so the raw is the integer +// left-shifted 16 — we right-shift to recover it (exact, since the +// test values are integers in [0, 1000)). +std::uint32_t decodeFpx16(const std::uint8_t* bytes, std::uint32_t size) { + EXPECT_EQ(size, sizeof(std::uint32_t)); + std::uint32_t raw = 0; + for (std::uint32_t i = 0; i < size; ++i) { + raw |= static_cast(bytes[i]) << (8 * i); + } + return raw >> 16; +} + +// Collects the stateDiff items (the test-local report). +std::vector +collectStateDiff(const laige::World& a, const laige::World& b, + std::uint32_t maxItems) { + std::vector items; + items.reserve(maxItems); + const std::uint32_t total = a.stateDiff(b, maxItems, [&](const auto& item) { + items.push_back(item); + }); + EXPECT_LE(items.size(), maxItems); + (void)total; + return items; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The log event capture (the determinism_tests MemorySink pattern — +// Warn+ only, rate limiting off) +// --------------------------------------------------------------------------- + +namespace { + +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; + entries.push_back(Entry{record.severity, std::string(record.subsystem), + std::string(record.event), + std::string(record.message)}); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + 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; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The component-state hash (M1-DET-05; entity.h componentStateHash) +// --------------------------------------------------------------------------- + +TEST(StateDiff, ComponentStateHashMatchesStateHashWithoutPrng) { + // No system registered: the full state hash and the component-state + // hash cover the SAME state (there is no substream to exclude) — + // they must be equal, and a component change moves both. + laige::World a = makeWorld(8, 7); + static_cast(a.registerComponent()); + const laige::Result e1 = a.create(); + ASSERT_TRUE(e1.ok()); + static_cast(a.addComponent(e1.value(), RdValue{ + laige::fpx16_16::fromInt32(1)})); + EXPECT_EQ(a.componentStateHash(0), a.stateHash(0)); + EXPECT_EQ(a.componentStateHash(5), a.stateHash(5)); + // A component byte change moves the component-state hash (and the + // full one, which still equals it — no substream to exclude). + const std::uint64_t before = a.componentStateHash(0); + RdValue* v = a.get(e1.value()); + ASSERT_NE(v, nullptr); + v->v = laige::fpx16_16::fromInt32(2); + EXPECT_NE(a.componentStateHash(0), before); + EXPECT_EQ(a.componentStateHash(0), a.stateHash(0)); +} + +// --------------------------------------------------------------------------- +// The bounded state diff (M1-DET-05; entity.h World::stateDiff) +// --------------------------------------------------------------------------- + +TEST(StateDiff, IdenticalWorldsReportNothing) { + const std::uint64_t seed = 0x4242; + RdScenario a = makeRdWorld(seed); + RdScenario b = makeRdWorld(seed); + // Identical initial state: nothing differs. + EXPECT_TRUE(collectStateDiff(a.world, b.world, + laige::kMaxReplayDiffEntries).empty()); + // Run identical ticks on both (unarmed: no draws, no mutations): + // still nothing differs. + gRdDrawFromTick = 0; + static_cast(runTicksComponentState(a.world, 5)); + static_cast(runTicksComponentState(b.world, 5)); + EXPECT_TRUE(collectStateDiff(a.world, b.world, + laige::kMaxReplayDiffEntries).empty()); +} + +TEST(StateDiff, ComponentBytesAndOrder) { + const std::uint64_t seed = 0x4242; + RdScenario a = makeRdWorld(seed); + RdScenario b = makeRdWorld(seed); + const std::uint16_t slot1a = a.e1.id; + const std::uint16_t slot2a = a.e2.id; + // e1 differs, e2 matches (one component-type difference). + a.world.get(a.e1)->v = laige::fpx16_16::fromInt32(7); + std::vector items = + collectStateDiff(a.world, b.world, laige::kMaxReplayDiffEntries); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].slot, slot1a); + EXPECT_EQ(items[0].componentId, 1u); + EXPECT_TRUE(items[0].presentInA && items[0].presentInB); + EXPECT_EQ(decodeFpx16(items[0].bytesA, items[0].size), 7u); + EXPECT_EQ(decodeFpx16(items[0].bytesB, items[0].size), 0u); + + // Both entities differ: canonical order is slots ASCENDING (e2 lands + // in the lower slot — LIFO slot assignment — so e2 is reported + // first). + a.world.get(a.e2)->v = laige::fpx16_16::fromInt32(9); + items = collectStateDiff(a.world, b.world, laige::kMaxReplayDiffEntries); + ASSERT_EQ(items.size(), 2u); + const std::uint16_t lowSlot = slot1a < slot2a ? slot1a : slot2a; + const std::uint16_t highSlot = slot1a < slot2a ? slot2a : slot1a; + EXPECT_EQ(items[0].slot, lowSlot); + EXPECT_EQ(items[1].slot, highSlot); +} + +TEST(StateDiff, PresenceDifferences) { + const std::uint64_t seed = 0x4242; + RdScenario a = makeRdWorld(seed); + RdScenario b = makeRdWorld(seed); + const std::uint16_t slot1a = a.e1.id; + const std::uint16_t slot2a = a.e2.id; + + // Entity presence: b's e2 is destroyed (a_only, component 0). + static_cast(b.world.destroy(b.e2)); + std::vector items = + collectStateDiff(a.world, b.world, laige::kMaxReplayDiffEntries); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].slot, slot2a); + EXPECT_EQ(items[0].componentId, 0u); + EXPECT_TRUE(items[0].presentInA && !items[0].presentInB); + EXPECT_EQ(items[0].size, 0u); + EXPECT_EQ(items[0].bytesA, nullptr); + EXPECT_EQ(items[0].bytesB, nullptr); + + // Component presence: both worlds keep only e1, and b's e1 has lost + // its component (a_only, component 1). + static_cast(a.world.destroy(a.e2)); // b's e2 was destroyed above + static_cast(b.world.removeComponent(b.e1)); + items = collectStateDiff(a.world, b.world, laige::kMaxReplayDiffEntries); + ASSERT_EQ(items.size(), 1u); + EXPECT_EQ(items[0].slot, slot1a); + EXPECT_EQ(items[0].componentId, 1u); + EXPECT_TRUE(items[0].presentInA && !items[0].presentInB); + EXPECT_EQ(decodeFpx16(items[0].bytesA, items[0].size), 0u); + EXPECT_EQ(items[0].bytesB, nullptr); +} + +TEST(StateDiff, BoundedReportCountsEverything) { + const std::uint64_t seed = 0x4242; + const std::uint32_t count = 10; + laige::World a = makeWorld(count + 4, seed); + laige::World b = makeWorld(count + 4, seed); + static_cast(a.registerComponent()); + static_cast(b.registerComponent()); + for (std::uint32_t i = 1; i <= count; ++i) { + const laige::Result ea = a.create(); + const laige::Result eb = b.create(); + ASSERT_TRUE(ea.ok() && eb.ok()); + static_cast(a.addComponent( + ea.value(), RdValue{laige::fpx16_16::fromInt32(static_cast(i))})); + static_cast(b.addComponent( + eb.value(), RdValue{laige::fpx16_16::fromInt32(0)})); + } + // All 10 entities differ; the report is bounded to 3, the count is + // exact. + std::vector items; + items.reserve(3); + const std::uint32_t total = a.stateDiff(b, 3, [&](const auto& item) { + items.push_back(item); + }); + EXPECT_EQ(total, count); + ASSERT_EQ(items.size(), 3u); + // Canonical order: strictly ascending slots. + for (std::size_t i = 1; i < items.size(); ++i) { + EXPECT_LT(items[i - 1].slot, items[i].slot); + } + for (const auto& item : items) { + EXPECT_EQ(item.componentId, 1u); + EXPECT_TRUE(item.presentInA && item.presentInB); + EXPECT_NE(decodeFpx16(item.bytesA, item.size), + decodeFpx16(item.bytesB, item.size)); + } +} + +// --------------------------------------------------------------------------- +// The diff driver (M1-DET-05; replay_diff.h diffReplays) +// --------------------------------------------------------------------------- + +namespace { + +// Records two scenarios (seeds seedA/seedB, `ticks` ticks each) and +// runs diffReplays on the caller's FRESH scenario worlds `wa`/`wb` +// (the recording consumed ticks — the diff replays from scratch). +// The caller owns `wa`/`wb`: the result's entry byte views point into +// their component columns (replay_diff.h — non-owning views, PERF-005) +// and are read AFTER runDiff returns. On success writes the result to +// `out` and returns true; on a failure records a test failure and +// returns false. +bool runDiff(std::uint64_t seedA, std::uint64_t seedB, + std::uint64_t ticksA, std::uint64_t ticksB, + std::uint32_t maxEntries, const char* nameA, const char* nameB, + RdScenario& wa, RdScenario& wb, + laige::ReplayDiffResult* out) { + RdScenario recA = makeRdWorld(seedA); + recordRun(recA.world, makeConfig(seedA), ticksA, tempPath(nameA)); + RdScenario recB = makeRdWorld(seedB); + recordRun(recB.world, makeConfig(seedB), ticksB, tempPath(nameB)); + + const laige::Result logAR = + laige::loadReplay(tempPath(nameA)); + if (!logAR.ok()) { + ADD_FAILURE() << "loadReplay A failed: " << laige::errorText(logAR.error()); + return false; + } + const laige::Result logBR = + laige::loadReplay(tempPath(nameB)); + if (!logBR.ok()) { + ADD_FAILURE() << "loadReplay B failed: " << laige::errorText(logBR.error()); + return false; + } + + const laige::Result diff = + laige::diffReplays(logAR.value(), logBR.value(), wa.world, wb.world, + makeConfig(seedA), makeConfig(seedB), maxEntries); + if (!diff.ok()) { + ADD_FAILURE() << "diffReplays failed: " << laige::errorText(diff.error()); + return false; + } + *out = diff.value(); + return true; +} + +} // namespace + +TEST(ReplayDiff, IdenticalReplaysSameSeed) { + gRdDrawFromTick = 0; // never draw: the component state never changes + laige::ReplayDiffResult r; + RdScenario wa = makeRdWorld(0x11111111); + RdScenario wb = makeRdWorld(0x11111111); + ASSERT_TRUE(runDiff(0x11111111, 0x11111111, 4, 4, + laige::kReplayDiffDefaultEntries, + "rd-ident-a.log", "rd-ident-b.log", wa, wb, &r)); + EXPECT_TRUE(r.identical); + EXPECT_FALSE(r.lengthDivergence); + EXPECT_EQ(r.firstDivergentTick, 0u); // the documented placeholder + EXPECT_FALSE(r.fullStateDivergent); + EXPECT_EQ(r.framesA, 4u); + EXPECT_EQ(r.framesB, 4u); + EXPECT_EQ(r.differingItems, 0u); + EXPECT_TRUE(r.entries.empty()); +} + +TEST(ReplayDiff, ZeroFrameLogsIdentical) { + gRdDrawFromTick = 0; + laige::ReplayDiffResult r; + RdScenario wa = makeRdWorld(0x11111111); + RdScenario wb = makeRdWorld(0x11111111); + ASSERT_TRUE(runDiff(0x11111111, 0x11111111, 0, 0, + laige::kReplayDiffDefaultEntries, + "rd-zero-a.log", "rd-zero-b.log", wa, wb, &r)); + EXPECT_TRUE(r.identical); + EXPECT_EQ(r.framesA, 0u); + EXPECT_EQ(r.framesB, 0u); + EXPECT_FALSE(r.fullStateDivergent); // same seed: same substreams +} + +TEST(ReplayDiff, InitialStateDivergesAtTick0) { + gRdDrawFromTick = 0; + // Pre-seed b's e1 differently (the recording sees it — a different + // initial state): the divergence is at tick 0. + const std::uint64_t seed = 0x11111111; + RdScenario recA = makeRdWorld(seed); + recordRun(recA.world, makeConfig(seed), 2, tempPath("rd-tick0-a.log")); + RdScenario recB = makeRdWorld(seed); + recB.world.get(recB.e1)->v = laige::fpx16_16::fromInt32(7); + recordRun(recB.world, makeConfig(seed), 2, tempPath("rd-tick0-b.log")); + + RdScenario wa = makeRdWorld(seed); + RdScenario wb = makeRdWorld(seed); + wb.world.get(wb.e1)->v = laige::fpx16_16::fromInt32(7); + + const laige::Result logAR = + laige::loadReplay(tempPath("rd-tick0-a.log")); + ASSERT_TRUE(logAR.ok()); + const laige::Result logBR = + laige::loadReplay(tempPath("rd-tick0-b.log")); + ASSERT_TRUE(logBR.ok()); + + // maxEntries = 1: the report is bounded, the count is exact. + const laige::Result diff = + laige::diffReplays(logAR.value(), logBR.value(), wa.world, wb.world, + makeConfig(seed), makeConfig(seed), 1); + ASSERT_TRUE(diff.ok()) << laige::errorText(diff.error()); + const laige::ReplayDiffResult& r = diff.value(); + EXPECT_FALSE(r.identical); + EXPECT_FALSE(r.lengthDivergence); + EXPECT_EQ(r.firstDivergentTick, 0u); + EXPECT_TRUE(r.fullStateDivergent); // the initial state bytes differ + EXPECT_EQ(r.differingItems, 1u); + ASSERT_EQ(r.entries.size(), 1u); + EXPECT_EQ(r.entries[0].slot, wb.e1.id); + EXPECT_EQ(r.entries[0].componentId, 1u); + EXPECT_TRUE(r.entries[0].presentInA && r.entries[0].presentInB); + EXPECT_EQ(decodeFpx16(r.entries[0].bytesA, r.entries[0].size), 0u); + EXPECT_EQ(decodeFpx16(r.entries[0].bytesB, r.entries[0].size), 7u); +} + +TEST(ReplayDiff, LengthDivergence) { + gRdDrawFromTick = 0; + laige::ReplayDiffResult r; + RdScenario wa = makeRdWorld(0x11111111); + RdScenario wb = makeRdWorld(0x11111111); + ASSERT_TRUE(runDiff(0x11111111, 0x11111111, 5, 2, + laige::kReplayDiffDefaultEntries, + "rd-length-a.log", "rd-length-b.log", wa, wb, &r)); + EXPECT_FALSE(r.identical); + EXPECT_TRUE(r.lengthDivergence); + EXPECT_EQ(r.firstDivergentTick, 3u); // the first tick only A has + EXPECT_EQ(r.framesA, 5u); + EXPECT_EQ(r.framesB, 2u); + EXPECT_EQ(r.differingItems, 0u); + EXPECT_TRUE(r.entries.empty()); +} + +// --------------------------------------------------------------------------- +// The roadmap's integration test: two replays that first diverge at +// tick 37 report tick 37 and the diverged component. +// --------------------------------------------------------------------------- + +TEST(ReplayDiff, ThirtySevenTickDivergence) { + // The deferred draw arms at completed tick 37: the component state + // is identical (and the only full-state divergence is the substream + // seed) through tick 36, and the first component divergence lands + // exactly at tick 37 — the tick-37 report the step's Verify names. + gRdDrawFromTick = 37; + const std::uint64_t seedA = 0x11111111; + const std::uint64_t seedB = 0x22222222; + + laige::ReplayDiffResult r; + RdScenario wa = makeRdWorld(seedA); + RdScenario wb = makeRdWorld(seedB); + ASSERT_TRUE(runDiff(seedA, seedB, 60, 60, laige::kReplayDiffDefaultEntries, + "rd-37-a.log", "rd-37-b.log", wa, wb, &r)); + gRdDrawFromTick = 0; + + EXPECT_FALSE(r.identical); + EXPECT_FALSE(r.lengthDivergence); + EXPECT_EQ(r.firstDivergentTick, 37u); + EXPECT_TRUE(r.fullStateDivergent); // the substream seed differs at tick 0 + EXPECT_EQ(r.framesA, 60u); + EXPECT_EQ(r.framesB, 60u); + // Both entities carry the drawn value at tick 37: two differences, + // canonical slot order (ascending), the component is RdValue (id 1). + EXPECT_EQ(r.differingItems, 2u); + ASSERT_EQ(r.entries.size(), 2u); + const std::uint16_t lowSlot = r.entries[0].slot; + const std::uint16_t highSlot = r.entries[1].slot; + EXPECT_LT(lowSlot, highSlot); + for (const laige::World::StateDiffItem& item : r.entries) { + EXPECT_EQ(item.componentId, 1u); + EXPECT_TRUE(item.presentInA && item.presentInB); + // The two seeds drew different values at tick 37 (independent + // xorshift substreams — a match would be a 1-in-1000 flake, and + // the fixed seeds make it a deterministic property of this test). + EXPECT_NE(decodeFpx16(item.bytesA, item.size), + decodeFpx16(item.bytesB, item.size)); + } + // Machine-greppable identity line (docs/testing.md §4): the seeds, + // the divergence tick, and the drawn values (the KAT of the + // scenario: a change to the draw path or the diff fails here). + std::printf("replay-diff-37 seed_a=0x%016llx seed_b=0x%016llx tick=37 " + "items=2 value_a=%u value_b=%u\n", + static_cast(seedA), + static_cast(seedB), + decodeFpx16(r.entries[0].bytesA, r.entries[0].size), + decodeFpx16(r.entries[0].bytesB, r.entries[0].size)); + std::fflush(stdout); +} + +// The KAT for the scenario's draw: at the arm tick (37), each entity +// draws once from the world's system-1 substream (derived from +// (seed, 1)) reduced to [0, 1000) — in the deterministic row order, +// so the LOWER slot (e2) takes the FIRST draw and e1 the second — +// pinned against an independent Prng (the test-local check of the +// draw path against the house PRNG contract). +TEST(ReplayDiff, DrawValueMatchesIndependentPrng) { + const std::uint64_t seed = 0x11111111; + laige::Prng ref = laige::Prng::deriveSubstream(seed, 1); + const std::uint32_t drawLow = ref.next_range(0, 1000); // e2 (slot 62) + const std::uint32_t drawHigh = ref.next_range(0, 1000); // e1 (slot 63) + + gRdDrawFromTick = 37; + RdScenario a = makeRdWorld(seed); + // Exactly 37 ticks: the arm tick draws once per entity — the + // values end at the two consecutive draws of the substream. + recordRun(a.world, makeConfig(seed), 37, tempPath("rd-kat-a.log")); + gRdDrawFromTick = 0; + + RdValue* v1 = a.world.get(a.e1); + RdValue* v2 = a.world.get(a.e2); + ASSERT_NE(v1, nullptr); + ASSERT_NE(v2, nullptr); + EXPECT_EQ(v1->v, + laige::fpx16_16::fromInt32(static_cast(drawHigh))); + EXPECT_EQ(v2->v, + laige::fpx16_16::fromInt32(static_cast(drawLow))); + std::printf("replay-diff-37-kat seed=0x%016llx tick=37 draw_low=%u " + "draw_high=%u\n", + static_cast(seed), drawLow, drawHigh); + std::fflush(stdout); +} + +// --------------------------------------------------------------------------- +// The rejections (CORE-008: never silent — structured warns + errors) +// --------------------------------------------------------------------------- + +TEST(ReplayDiff, IdentityMismatchRejected) { + MemorySink* sink = installCaptureSink(); + const std::uint64_t seed = 42; + const laige::EngineConfig config = makeConfig(seed); + + gRdDrawFromTick = 0; + RdScenario a = makeRdWorld(seed); + recordRun(a.world, config, 4, tempPath("rd-ident-a.log")); + RdScenario b = makeRdWorld(seed); + recordRun(b.world, config, 4, tempPath("rd-ident-b.log")); + RdScenario wa = makeRdWorld(seed); + RdScenario wb = makeRdWorld(seed); + + const laige::Result logAR = + laige::loadReplay(tempPath("rd-ident-a.log")); + ASSERT_TRUE(logAR.ok()); + const laige::Result logBR = + laige::loadReplay(tempPath("rd-ident-b.log")); + ASSERT_TRUE(logBR.ok()); + + // Control: the matching identity diff is empty and the run succeeds. + EXPECT_TRUE(laige::replayIdentityDiff(logAR.value(), wa.world, config) + .empty()); + EXPECT_TRUE(laige::diffReplays(logAR.value(), logBR.value(), wa.world, + wb.world, config, config) + .ok()); + EXPECT_EQ(countEvents(*sink, "diff_identity_mismatch"), 0u); + + // Case 1: logA's config tick rate differs -> A is named, once. + laige::EngineConfig tickCfg = config; + tickCfg.tickRateHz = 120; + const laige::Result r1 = + laige::diffReplays(logAR.value(), logBR.value(), wa.world, wb.world, + tickCfg, config); + EXPECT_EQ(r1.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "diff_identity_mismatch"), 1u); + + // Case 2: logB's config churn budget differs -> B is named, once + // (the cumulative count is now 2). + laige::EngineConfig churnCfg = config; + churnCfg.churnPerFrameBudget = 255; + const laige::Result r2 = + laige::diffReplays(logAR.value(), logBR.value(), wa.world, wb.world, + config, churnCfg); + EXPECT_EQ(r2.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "diff_identity_mismatch"), 2u); + + // Case 3: BOTH mismatch -> one report names both (the cumulative + // count is now 4). + const laige::Result r3 = + laige::diffReplays(logAR.value(), logBR.value(), wa.world, wb.world, + tickCfg, churnCfg); + EXPECT_EQ(r3.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "diff_identity_mismatch"), 4u); + restoreLogger(); +} + +TEST(ReplayDiff, DeterminismDisabledRejected) { + // The M1-DET-03 precedent: the log is recorded UNDER the disabled + // config (so its config hash matches — the identity check passes) + // and a non-deterministic world; the diff is then rejected by the + // determinism check itself, with the structured warn naming the log. + MemorySink* sink = installCaptureSink(); + const std::uint64_t seed = 42; + const laige::EngineConfig config = makeConfig(seed); + laige::EngineConfig offCfg = config; + offCfg.determinism.enabled = false; + + gRdDrawFromTick = 0; + RdScenario a = makeRdWorld(seed); + recordRun(a.world, config, 4, tempPath("rd-det-a.log")); + RdScenario b = makeRdWorld(seed, /*deterministic*/ false); + recordRun(b.world, offCfg, 4, tempPath("rd-det-b.log")); + RdScenario wa = makeRdWorld(seed); + RdScenario wb = makeRdWorld(seed, /*deterministic*/ false); + + const laige::Result logAR = + laige::loadReplay(tempPath("rd-det-a.log")); + ASSERT_TRUE(logAR.ok()); + const laige::Result logBR = + laige::loadReplay(tempPath("rd-det-b.log")); + ASSERT_TRUE(logBR.ok()); + + // The identity checks pass (each log matches its own config) — + // the rejection is the determinism check, not the identity check. + EXPECT_TRUE(laige::replayIdentityDiff(logAR.value(), wa.world, config) + .empty()); + EXPECT_TRUE(laige::replayIdentityDiff(logBR.value(), wb.world, offCfg) + .empty()); + const laige::Result r = + laige::diffReplays(logAR.value(), logBR.value(), wa.world, wb.world, + config, offCfg); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "diff_determinism_disabled"), 1u); + EXPECT_EQ(countEvents(*sink, "diff_identity_mismatch"), 0u); + restoreLogger(); +} diff --git a/tests/replay/CMakeLists.txt b/tests/replay/CMakeLists.txt index 82c2666..9aad690 100644 --- a/tests/replay/CMakeLists.txt +++ b/tests/replay/CMakeLists.txt @@ -1,4 +1,4 @@ -# laige-replay CTest suite (M1-DET-03). +# laige-replay CTest suite (M1-DET-03 + M1-DET-05). # # The tool is tested end to end against a recorded run of the engine's # built-in scenario (`laige-run --headless ... --replay` — the M1-DET-02 @@ -11,6 +11,15 @@ # (ADR 0002: a mismatch is a rejected replay, never a silent # divergence). # +# The diff mode (M1-DET-05, `laige-replay --diff`): the diff tests +# record two scenario logs (the template's SETUP/SETUP2 steps) and +# assert the structured stdout report grammar + the exit codes +# (0 identical, 1 divergent/length-divergent, 2 usage/identity/IO) — +# replay_diff_identical (two 16-tick recordings, same fixture), +# replay_diff_length (16 vs 10 ticks), replay_diff_identity (a +# budget-128 config against a budget-64 log), and replay_diff_usage +# (--diff with one log path). +# # Each test is a generated `cmake -P` check script # (expect-replay-result.cmake.in, the tests/api + tests/detcheck # pattern) that asserts the exit code AND the required stdout/stderr @@ -24,25 +33,28 @@ set(LAIGE_REPLAY_TEST_ENV "LAIGE_RUN=$" "LAIGE_FIXTURE=${CMAKE_CURRENT_SOURCE_DIR}/fixtures/replay_smoke.json" "LAIGE_WRONGSEED=${CMAKE_CURRENT_SOURCE_DIR}/fixtures/replay_smoke_wrongseed.json" + "LAIGE_DIFFBUDGET=${CMAKE_CURRENT_SOURCE_DIR}/fixtures/replay_smoke_budget128.json" "LAIGE_OUT_DIR=${CMAKE_CURRENT_BINARY_DIR}") -# laige_replay_test(name [SETUP s] [CMD c] [SECOND_CMD c] [WRITE_OUT p] -# [DERIVE mode] [DERIVED_PATH p] [EXPECT_EXIT n] -# [EXPECT_LINES n] [EXPECT_IDENTICAL bool] -# [PERTURB_TICK n] [CHECK_OUT regex]* [CHECK_ERR regex]*) +# laige_replay_test(name [SETUP s] [SETUP2 s] [CMD c] [SECOND_CMD c] +# [WRITE_OUT p] [DERIVE mode] [DERIVED_PATH p] +# [EXPECT_EXIT n] [EXPECT_LINES n] [EXPECT_IDENTICAL +# bool] [PERTURB_TICK n] [CHECK_OUT regex]* +# [CHECK_ERR regex]*) # Builds one generated check script; CMD/SECOND_CMD may reference -# (expanded at run -# time from the environment). +# +# (expanded at run time from the environment). function(laige_replay_test name) - set(oneValueArgs SETUP CMD SECOND_CMD WRITE_OUT DERIVE DERIVED_PATH - EXPECT_EXIT EXPECT_LINES EXPECT_IDENTICAL - PERTURB_TICK) + set(oneValueArgs SETUP SETUP2 CMD SECOND_CMD WRITE_OUT DERIVE + DERIVED_PATH EXPECT_EXIT EXPECT_LINES + EXPECT_IDENTICAL PERTURB_TICK) set(multiValueArgs CHECK_OUT CHECK_ERR) cmake_parse_arguments(ARG "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) # The template's @VAR@ names (configure_file @ONLY scope). set(TEST_NAME "${name}") set(SETUP_CMD "${ARG_SETUP}") + set(SETUP2_CMD "${ARG_SETUP2}") set(CMD "${ARG_CMD}") set(SECOND_CMD "${ARG_SECOND_CMD}") set(WRITE_OUT "${ARG_WRITE_OUT}") @@ -185,3 +197,62 @@ laige_replay_test(replay_log_missing CMD " --log /does_not_exist.log --config " EXPECT_EXIT 2 CHECK_ERR "laige-replay: log:") + +# --------------------------------------------------------------------------- +# The diff mode (M1-DET-05): `laige-replay --diff `. +# Each test records its scenario log(s) via the template's SETUP/SETUP2 +# steps (laige-run --headless ... --replay, the engine's built-in +# registrations) and asserts the structured stdout report grammar + +# the exit codes (0 identical, 1 divergent/length, 2 identity/usage/IO). +# --------------------------------------------------------------------------- + +# The shared 16-tick recording (the diff fixture runs). +set(DIFF_RECORD_16 + " --headless --ticks 16 --replay /diff_16.log") +set(DIFF_RECORD_10 + " --headless --ticks 10 --replay /diff_10.log") + +# 10) identical: two 16-tick recordings of the SAME fixture (same +# seed 42) -> the component state is bit-identical over all 16 +# ticks: exit 0, result=identical, zero differing items, and the +# full state (PRNG substreams) also matches (same seed). +laige_replay_test(replay_diff_identical + SETUP "${DIFF_RECORD_16}" + SETUP2 " --headless --ticks 16 --replay /diff_16_b.log" + CMD " --diff /diff_16.log /diff_16_b.log --config " + EXPECT_EXIT 0 + CHECK_OUT "replay_diff result=identical" "frames_a=16" "frames_b=16" + "first_divergent_tick=0" "full_state_divergent=false" + "differing_items=0" "reported_items=0" + CHECK_ERR "replays identical over 17 ticks") + +# 11) length divergence: a 16-tick log vs a 10-tick log (same fixture): +# the shared ticks match, but the frame counts differ -> exit 1, +# result=length_divergence, first tick only one replay has is 11 +# (0-based: the 11th completed tick), zero differing items. +laige_replay_test(replay_diff_length + SETUP "${DIFF_RECORD_16}" + SETUP2 "${DIFF_RECORD_10}" + CMD " --diff /diff_16.log /diff_10.log --config " + EXPECT_EXIT 1 + CHECK_OUT "replay_diff result=length_divergence" "frames_a=16" + "frames_b=10" "first_divergent_tick=11" + "length_divergence=true" "differing_items=0" + CHECK_ERR "length divergence: replay A has 16 frames, replay B has 10") + +# 12) identity mismatch (ADR 0002): a 16-tick log recorded under the +# budget-64 fixture, diffed against ITSELF under the budget-128 +# config (same seed 42): the config_hash differs -> the diff is +# REJECTED (never a silent divergence) — exit 2, the report names +# the config_hash field. +laige_replay_test(replay_diff_identity + SETUP "${DIFF_RECORD_16}" + CMD " --diff /diff_16.log /diff_16.log --config " + EXPECT_EXIT 2 + CHECK_ERR "replay identity mismatch" "config_hash" "DIFFERS") + +# 13) usage error: --diff with a single log path (no second) -> exit 2. +laige_replay_test(replay_diff_usage + CMD " --diff /diff_16.log" + EXPECT_EXIT 2 + CHECK_ERR "--diff needs two log paths") diff --git a/tests/replay/expect-replay-result.cmake.in b/tests/replay/expect-replay-result.cmake.in index 92a797a..cf787d4 100644 --- a/tests/replay/expect-replay-result.cmake.in +++ b/tests/replay/expect-replay-result.cmake.in @@ -21,6 +21,7 @@ cmake_minimum_required(VERSION 3.16) # --- Inputs (substituted at configure time) ------------------------------ set(_test_name "@TEST_NAME@") set(_setup_cmd "@SETUP_CMD@") +set(_setup2_cmd "@SETUP2_CMD@") set(_cmd "@CMD@") set(_cmd2 "@SECOND_CMD@") set(_expect_exit "@EXPECT_EXIT@") @@ -39,6 +40,7 @@ function(expand_vars var) string(REPLACE "" "$ENV{LAIGE_RUN}" _s "${_s}") string(REPLACE "" "$ENV{LAIGE_FIXTURE}" _s "${_s}") string(REPLACE "" "$ENV{LAIGE_WRONGSEED}" _s "${_s}") + string(REPLACE "" "$ENV{LAIGE_DIFFBUDGET}" _s "${_s}") string(REPLACE "" "$ENV{LAIGE_OUT_DIR}" _s "${_s}") set(${var} "${_s}" PARENT_SCOPE) endfunction() @@ -74,6 +76,19 @@ if(NOT _setup_cmd STREQUAL "") endif() endif() +# --- The optional second setup step (a second recorded scenario) ---------- +# (the diff tests record two logs: the first under one identity, the +# second under another) +if(NOT _setup2_cmd STREQUAL "") + expand_vars(_setup2_cmd) + run_cmd("${_setup2_cmd}" _setup2_out _setup2_err _setup2_rc) + if(NOT _setup2_rc EQUAL 0) + message(FATAL_ERROR "@TEST_NAME@: setup2 step failed [rc=${_setup2_rc}]\n" + "--- setup2 stdout ---\n${_setup2_out}\n" + "--- setup2 stderr ---\n${_setup2_err}") + endif() +endif() + # --- The command under test ------------------------------------------------ expand_vars(_cmd) run_cmd("${_cmd}" _out _err _rc) diff --git a/tests/replay/fixtures/replay_smoke_budget128.json b/tests/replay/fixtures/replay_smoke_budget128.json new file mode 100644 index 0000000..881e029 --- /dev/null +++ b/tests/replay/fixtures/replay_smoke_budget128.json @@ -0,0 +1,6 @@ +{ + "tick_rate_hz": 60, + "entity_budget": 128, + "churn_per_frame_budget": 256, + "seed": 42 +} diff --git a/tools/replay/laige-replay.cpp b/tools/replay/laige-replay.cpp index 4893a1c..91c88f7 100644 --- a/tools/replay/laige-replay.cpp +++ b/tools/replay/laige-replay.cpp @@ -10,14 +10,15 @@ // Usage (docs/api/replay.md, the "laige-replay" section): // // laige-replay --log --config [--expect ] +// laige-replay --diff --config [--entries ] // -// Loads the recorded replay log (the M1-DET-02 versioned format, -// laige/sim/replay.h), checks its replay identity against (the -// engine's world, the config) — a mismatch is a REJECTED REPLAY, -// never a silent divergence (ADR 0002) — replays the log headlessly -// on the engine's world (runReplay: one beginFrame() + one -// runSystems per recorded tick), and prints the per-tick state hashes -// (World::stateHash) as the hash line contract: +// Log mode loads the recorded replay log (the M1-DET-02 versioned +// format, laige/sim/replay.h), checks its replay identity against +// (the engine's world, the config) — a mismatch is a REJECTED +// REPLAY, never a silent divergence (ADR 0002) — replays the log +// headlessly on the engine's world (runReplay: one beginFrame() + +// one runSystems per recorded tick), and prints the per-tick state +// hashes (World::stateHash) as the hash line contract: // // // @@ -27,15 +28,33 @@ // (docs/api/detcheck.md). // // Exit codes (documented, stable for CI grepping): -// 0 the replay completed and, when --expect was given, every hash -// matches the baseline (the summary line goes to stderr; the hash -// lines are the ONLY stdout content — a redirect captures a clean -// baseline file) -// 1 a per-tick hash mismatch with the --expect baseline (the first -// divergence is reported to stderr) +// 0 log mode: the replay completed and, when --expect was given, +// every hash matches the baseline (the summary line goes to +// stderr; the hash lines are the ONLY stdout content — a redirect +// captures a clean baseline file). diff mode: the two replays' +// component states are identical over the aligned ticks. +// 1 log mode: a per-tick hash mismatch with the --expect baseline +// (the first divergence is reported to stderr). diff mode: a +// component-state or length divergence was found (the report is +// on stdout). // 2 usage, IO, config-parse, log-load, identity-mismatch, or // engine-create error (the message carries the actionable text) // +// Diff mode (M1-DET-05, laige/sim/replay_diff.h — the full contract): +// loads both logs, derives a per-log config (the base config with +// THAT log's header seed — the seed is the identity field the diff +// legitimately varies), identity-checks each log against its (world, +// config) — a mismatch is a REJECTED DIFF (never a silent divergence, +// ADR 0002; both logs are reported), then diffReplays: the lock-step +// tick walk aligned on World::componentStateHash (the PRNG substream +// state is excluded — it is a function of the master seed; the honest +// full-state report is the full_state_divergent flag), and the bounded +// state-difference report at the first divergent tick (World::stateDiff, +// ≤ --entries items — 0 = count only, default 16, cap 64). The report +// is the ONLY stdout content (a stable key=value grammar, documented +// in the usage text and docs/api/replay.md); the summary and +// diagnostics go to stderr. +// // Headless invariants (ARCH-003, NFR-8.11): this binary and everything // it links (laige-sim, laige-core) touch no GL, window, audio, or // input API. @@ -69,6 +88,7 @@ #include "laige/result.h" #include "laige/sim/engine.h" #include "laige/sim/replay.h" +#include "laige/sim/replay_diff.h" // diff mode (M1-DET-05) namespace { @@ -87,30 +107,61 @@ void printUsage(std::FILE* out) { std::fprintf(out, "Usage: laige-replay --log --config " "[--expect ]\n" + " laige-replay --diff --config " + " [--entries ]\n" "\n" - " --log the recorded replay log (required;\n" - " the laige/sim/replay.h version 1\n" - " format)\n" + " --log the recorded replay log (required in\n" + " log mode; the laige/sim/replay.h\n" + " version 1 format)\n" " --config the engine config of the recorded\n" " run (required; the replay identity is\n" " checked against it — ADR 0002: a\n" - " mismatch is a rejected replay)\n" + " mismatch is a rejected replay). In diff\n" + " mode the base config: each log's header\n" + " seed is copied onto a per-log copy\n" + " before the identity check (the seed is\n" + " the identity field the diff\n" + " legitimately varies)\n" " --expect compare the replay's per-tick hashes\n" " against the baseline hash stream\n" " (one ' ' line per tick,\n" " tick 0 first — the detcheck scenario\n" " contract); a mismatch exits 1 with the\n" " first divergence report\n" + " --diff diff two recorded replay logs (mutually\n" + " exclusive with --log/--expect): replay\n" + " both headlessly, align by tick, and\n" + " report the first divergent tick plus\n" + " the bounded state diff (the M1-DET-05\n" + " report — the \"Diff-mode stdout\" section\n" + " below)\n" + " --entries diff mode only: the bounded report size\n" + " — the maximum number of differing\n" + " (entity, component) item lines (0 =\n" + " count only; 1..64; default 16)\n" " --help, -h this help\n" "\n" - "stdout: exactly one ' ' line per tick (tick 0 = the\n" - "initial state; 16 lowercase hex hash digits) — nothing else. A\n" - "redirect captures a clean baseline file. All diagnostics and\n" - "the summary line go to stderr.\n" + "Log-mode stdout: exactly one ' ' line per tick\n" + "(tick 0 = the initial state; 16 lowercase hex hash digits) —\n" + "nothing else. A redirect captures a clean baseline file. All\n" + "diagnostics and the summary line go to stderr.\n" + "\n" + "Diff-mode stdout: a stable structured report (the M1-DET-05\n" + "contract, docs/api/replay.md): a banner line\n" + "'replay_diff result=',\n" + "then one line each of frames_a, frames_b,\n" + "first_divergent_tick, length_divergence, full_state_divergent,\n" + "differing_items, reported_items, then one\n" + "'item slot= component= presence=\n" + " a= b=' line per reported difference (component\n" + "0 = an entity-presence difference; '-' = that side absent).\n" "\n" - "Exit codes: 0 = ok (and the hashes match the baseline when\n" - "--expect was given), 1 = a per-tick hash mismatch with the\n" - "baseline, 2 = usage / IO / config / log / identity error.\n"); + "Exit codes — log mode: 0 = ok (and the hashes match the\n" + "baseline when --expect was given), 1 = a per-tick hash mismatch\n" + "with the baseline, 2 = usage / IO / config / log / identity\n" + "error. Diff mode: 0 = the replays' component states are\n" + "identical, 1 = a divergence (component-state or length) was\n" + "found, 2 = usage / IO / config / log / identity / engine error.\n"); } // Portable file open (CPP-009 compile-time platform boundary — the @@ -169,6 +220,61 @@ void formatHex16(char* out /* 17 bytes */, std::uint64_t v) { static_cast(v)); } +// The field-by-field identity mismatch report for one log against one +// (world, config) (the ADR 0002 report — CORE-008: a mismatch is a +// rejected replay/diff, never a silent divergence). Returns true when +// the identity mismatches (the report was printed). +bool reportIdentityMismatch(std::FILE* out, const char* label, + const laige::ReplayLog& log, laige::World& world, + const laige::EngineConfig& config) { + const laige::ReplayIdentityDiff diff = + laige::replayIdentityDiff(log, world, config); + if (diff.empty()) return false; + const laige::ReplayIdentity recorded = log.identity; + const laige::ReplayIdentity expected = + laige::makeReplayIdentity(world, config); + char a[17], b[17]; + std::fprintf(out, + "laige-replay: replay identity mismatch (%s) — the log " + "was recorded under a different identity; a mismatch is " + "a rejected replay, never a silent divergence (ADR 0002)\n", + label); + reportIdentityField(out, "seed", diff.seed, + (formatHex16(a, recorded.seed), a), + (formatHex16(b, expected.seed), b)); + reportIdentityField(out, "tick_rate_hz", diff.tickRateHz, + (std::to_string(recorded.tickRateHz)).c_str(), + (std::to_string(expected.tickRateHz)).c_str()); + reportIdentityField(out, "component_schema_hash", + diff.componentSchemaHash, + (formatHex16(a, recorded.componentSchemaHash), a), + (formatHex16(b, expected.componentSchemaHash), b)); + reportIdentityField(out, "math_backend_id", diff.mathBackendId, + (std::to_string(recorded.mathBackendId)).c_str(), + (std::to_string(expected.mathBackendId)).c_str()); + reportIdentityField(out, "config_hash", diff.configHash, + (formatHex16(a, recorded.configHash), a), + (formatHex16(b, expected.configHash), b)); + std::fprintf(out, + "laige-replay: re-record the log or pass the config " + "(and registrations) that produced it\n"); + return true; +} + +// The 2 * n lowercase hex digits of a byte string (the diff report's +// a=/b= fields); "-" when the side is absent (null). +std::string formatHexBytes(const std::uint8_t* bytes, std::size_t n) { + if (bytes == nullptr) return "-"; + static const char kHexDigits[] = "0123456789abcdef"; + std::string out; + out.reserve(n * 2); + for (std::size_t i = 0; i < n; ++i) { + out.push_back(kHexDigits[bytes[i] >> 4]); + out.push_back(kHexDigits[bytes[i] & 0x0Fu]); + } + return out; +} + // One baseline line (the hash line contract, detcheck scenario form): // ' ' — tick the exact decimal of the line index (no // padding, no leading zeros), one space, 16 lowercase hex digits. @@ -209,8 +315,12 @@ int main(int argc, char** argv) { std::string logPath; std::string configPath; std::string baselinePath; + std::string diffPathA; + std::string diffPathB; bool hasLog = false; bool hasConfig = false; + bool hasDiff = false; + std::uint32_t diffEntries = laige::kReplayDiffDefaultEntries; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; @@ -239,6 +349,38 @@ int main(int argc, char** argv) { return 2; } baselinePath = argv[++i]; + } else if (arg == "--diff") { + if (i + 2 >= argc) { + std::fprintf(stderr, + "laige-replay: --diff needs two log paths\n"); + printUsage(stderr); + return 2; + } + hasDiff = true; + diffPathA = argv[++i]; + diffPathB = argv[++i]; + } else if (arg == "--entries") { + if (i + 1 >= argc) { + std::fprintf(stderr, + "laige-replay: --entries needs a number\n"); + printUsage(stderr); + return 2; + } + // strtoul (no exceptions — NFR-8.10): reject non-numeric, + // negative (a leading '-' is rejected explicitly — strtoul would + // otherwise parse the digits after it), and over-bound values. + const char* number = argv[++i]; + char* end = nullptr; + const unsigned long value = std::strtoul(number, &end, 10); + if (number[0] == '-' || end == number || *end != '\0' || + value > laige::kMaxReplayDiffEntries) { + std::fprintf(stderr, + "laige-replay: --entries needs an integer in 0..%u\n", + static_cast(laige::kMaxReplayDiffEntries)); + printUsage(stderr); + return 2; + } + diffEntries = static_cast(value); } else if (arg == "--help" || arg == "-h") { printUsage(stdout); return 0; @@ -249,7 +391,21 @@ int main(int argc, char** argv) { return 2; } } - if (!hasLog || !hasConfig) { + if (hasDiff) { + if (!hasConfig) { + std::fprintf(stderr, + "laige-replay: --config is required\n"); + printUsage(stderr); + return 2; + } + if (hasLog || !baselinePath.empty()) { + std::fprintf(stderr, + "laige-replay: --diff is mutually exclusive with " + "--log/--expect\n"); + printUsage(stderr); + return 2; + } + } else if (!hasLog || !hasConfig) { std::fprintf(stderr, "laige-replay: --log and --config " "are required\n"); @@ -281,6 +437,169 @@ int main(int argc, char** argv) { return 2; } + if (hasDiff) { + // --------------------------------------------------------------- + // Diff mode (M1-DET-05): replay both logs on two worlds, align by + // tick, report the first divergence + the bounded state diff + // (laige/sim/replay_diff.h — the full contract). + // --------------------------------------------------------------- + + // The two logs (M1-DET-02 format; the bounded read + the + // structural validation are loadReplay's). + const laige::Result logAR = + laige::loadReplay(diffPathA); + if (logAR.isError()) { + std::fprintf(stderr, "laige-replay: logA: %s\n", + laige::errorText(logAR.error())); + return 2; + } + const laige::Result logBR = + laige::loadReplay(diffPathB); + if (logBR.isError()) { + std::fprintf(stderr, "laige-replay: logB: %s\n", + laige::errorText(logBR.error())); + return 2; + } + const laige::ReplayLog& logA = logAR.value(); + const laige::ReplayLog& logB = logBR.value(); + + // The per-log configs: the base config with THAT log's header + // seed (the seed is the identity field the diff legitimately + // varies — the other config fields stay as passed and are + // identity-checked below). + laige::EngineConfig configA = config.value(); + configA.seed = logA.identity.seed; + laige::EngineConfig configB = config.value(); + configB.seed = logB.identity.seed; + + // The two engines (the built-in registrations — this binary + // registers no game components of its own; the M1 scenario + // surface, see the header preamble: a log recorded with game- + // scenario registrations is a schema-mismatch rejected diff). + laige::Result engineAResult = + laige::Engine::create(configA); + if (engineAResult.isError()) { + std::fprintf(stderr, "laige-replay: engineA: %s\n", + laige::errorText(engineAResult.error())); + return 2; + } + laige::Engine engineA = std::move(engineAResult).takeValue(); + laige::Result engineBResult = + laige::Engine::create(configB); + if (engineBResult.isError()) { + std::fprintf(stderr, "laige-replay: engineB: %s\n", + laige::errorText(engineBResult.error())); + engineA.shutdown(); + return 2; + } + laige::Engine engineB = std::move(engineBResult).takeValue(); + laige::World& worldA = *engineA.world(); + laige::World& worldB = *engineB.world(); + + // The replay identity, per log (ADR 0002): a mismatch is a + // REJECTED diff — never a silent divergence. The report names + // every failing log with every differing field (actionable, + // LOG-002 shape). Both are checked so one report covers both. + bool identityMismatch = false; + identityMismatch |= + reportIdentityMismatch(stderr, "logA", logA, worldA, configA); + identityMismatch |= + reportIdentityMismatch(stderr, "logB", logB, worldB, configB); + if (identityMismatch) { + engineA.shutdown(); + engineB.shutdown(); + return 2; + } + + // The diff driver (M1-DET-05): identity + determinism checked, + // lock-step tick walk, the first divergence + the bounded state + // diff at that tick. + const laige::Result diff = + laige::diffReplays(logA, logB, worldA, worldB, configA, configB, + diffEntries); + if (diff.isError()) { + std::fprintf(stderr, "laige-replay: diff: %s\n", + laige::errorText(diff.error())); + engineA.shutdown(); + engineB.shutdown(); + return 2; + } + const laige::ReplayDiffResult& result = diff.value(); + + // The structured report: stdout ONLY (the stable M1-DET-05 report + // grammar, docs/api/replay.md — machine-greppable for CI and the + // editor replay viewer, M5-ED-15). + std::printf("replay_diff result=%s\n", + result.identical ? "identical" + : result.lengthDivergence ? "length_divergence" + : "divergent"); + std::printf("frames_a=%llu\n", + static_cast(result.framesA)); + std::printf("frames_b=%llu\n", + static_cast(result.framesB)); + std::printf("first_divergent_tick=%llu\n", + static_cast( + result.firstDivergentTick)); + std::printf("length_divergence=%s\n", + result.lengthDivergence ? "true" : "false"); + std::printf("full_state_divergent=%s\n", + result.fullStateDivergent ? "true" : "false"); + std::printf("differing_items=%u\n", result.differingItems); + std::printf("reported_items=%u\n", + static_cast(result.entries.size())); + for (const laige::World::StateDiffItem& item : result.entries) { + std::printf("item slot=%u component=%u presence=%s a=%s b=%s\n", + static_cast(item.slot), + static_cast(item.componentId), + item.componentId == 0 + ? (item.presentInA ? "a_only" : "b_only") + : (item.presentInA && item.presentInB + ? "both" + : (item.presentInA ? "a_only" : "b_only")), + formatHexBytes(item.bytesA, item.size).c_str(), + formatHexBytes(item.bytesB, item.size).c_str()); + } + std::fflush(stdout); + + // The human summary: stderr (stdout carries the report only). + if (result.identical) { + std::fprintf(stderr, + "laige-replay: replays identical over %llu ticks " + "(component state)\n", + static_cast(result.framesA + 1)); + } else if (result.lengthDivergence) { + std::fprintf(stderr, + "laige-replay: length divergence: replay A has %llu " + "frames, replay B has %llu — first tick only one " + "replay has: %llu\n", + static_cast(result.framesA), + static_cast(result.framesB), + static_cast( + result.firstDivergentTick)); + } else { + std::fprintf(stderr, + "laige-replay: first divergence at tick %llu — %u " + "differing state items, %u reported (stdout)\n", + static_cast( + result.firstDivergentTick), + result.differingItems, + static_cast(result.entries.size())); + } + if (result.fullStateDivergent) { + // Never silent (CORE-008): the component state may be identical + // while the PRNG draw position diverged (different seeds). + std::fprintf(stderr, + "laige-replay: note: the PRNG substream state " + "diverged (different seeds/inputs) even where the " + "component state matches (full_state_divergent=true)\n"); + } + + // The ordered shutdown (CONC-006), both engines. + engineA.shutdown(); + engineB.shutdown(); + return result.identical ? 0 : 1; + } + // The log (M1-DET-02 format; the bounded read + the structural // validation are loadReplay's). const laige::Result log = @@ -308,37 +627,8 @@ int main(int argc, char** argv) { // The replay identity (ADR 0002): a mismatch is a REJECTED replay — // never a silent divergence. The report names every differing field // with both sides' values (actionable, LOG-002 shape). - const laige::ReplayIdentityDiff diff = - laige::replayIdentityDiff(log.value(), world, config.value()); - if (!diff.empty()) { - const laige::ReplayIdentity recorded = log.value().identity; - const laige::ReplayIdentity expected = - laige::makeReplayIdentity(world, config.value()); - char a[17], b[17]; - std::fprintf(stderr, - "laige-replay: replay identity mismatch — the log was " - "recorded under a different identity; a mismatch is a " - "rejected replay, never a silent divergence (ADR " - "0002)\n"); - reportIdentityField(stderr, "seed", diff.seed, - (formatHex16(a, recorded.seed), a), - (formatHex16(b, expected.seed), b)); - reportIdentityField(stderr, "tick_rate_hz", diff.tickRateHz, - (std::to_string(recorded.tickRateHz)).c_str(), - (std::to_string(expected.tickRateHz)).c_str()); - reportIdentityField(stderr, "component_schema_hash", - diff.componentSchemaHash, - (formatHex16(a, recorded.componentSchemaHash), a), - (formatHex16(b, expected.componentSchemaHash), b)); - reportIdentityField(stderr, "math_backend_id", diff.mathBackendId, - (std::to_string(recorded.mathBackendId)).c_str(), - (std::to_string(expected.mathBackendId)).c_str()); - reportIdentityField(stderr, "config_hash", diff.configHash, - (formatHex16(a, recorded.configHash), a), - (formatHex16(b, expected.configHash), b)); - std::fprintf(stderr, - "laige-replay: re-record the log or pass the config " - "(and registrations) that produced it\n"); + if (reportIdentityMismatch(stderr, "the log", log.value(), world, + config.value())) { engine.shutdown(); return 2; }