Skip to content

[M1-DET-03] State hashing + replay runner (World::stateHash, runReplay, laige-replay) - #38

Merged
offdev merged 1 commit into
masterfrom
m1-det-03-state-hash-replay-runner
Sep 16, 2026
Merged

offdev merged 1 commit into
masterfrom
m1-det-03-state-hash-replay-runner

Conversation

@offdev

@offdev offdev commented Sep 16, 2026

Copy link
Copy Markdown
Owner

M1-DET-03 · State hashing + replay runner

The replay execution half, on top of M1-DET-02's recording and M1-ECS-05's deterministic iteration (roadmap M1-DET-03; FR-1.4/FR-11.3, TEST-004, ADR 0002, ARCH-010, CORE-008).

What lands

  • World::stateHash(tick) (entity.h + state_hash.cpp) — the deterministic 64-bit FNV-1a hash of the live sim state: tick, live handles (slot+generation), per non-empty archetype in lexicographic signature order (component ids ascending, row count, raw component bytes row-major in signature column order), per-system PRNG substream state (seed + state words). A pure function of the live state — convergent worlds (same live state, different histories) hash identically; dead-slot generations, the free-list order, empty archetypes, the registry, capacity, presentation, timing, guardrail counters, and the master seed are deliberately excluded (scope in/out documented in the header). Word-stream FNV-1a 64, big-endian per u64 (house convention); cold path: no allocation, const, no logging, never on the engine per-tick path.
  • Prng::statePart1()/statePart2() — read-only accessors for the substream state words that join the hash.
  • replayIdentityDiff + runReplay (replay.h/replay.cpp) — ADR 0002 enforced field by field (seed, tick rate, component schema hash, math backend id, config hash): a mismatch is a rejected replay (replay/identity_mismatch warn naming the fields), never a silent divergence; then determinism check → schedule once → one beginFrame()+runSystems per recorded tick; the result is the per-tick state-hash stream (size frameCount+1, tick 0 first).
  • laige-replay (tools/replay/) — laige-replay --log LOG --config CFG [--expect BASELINE]. stdout is only the <tick> <hash> lines (16 lowercase hex — a redirect captures a clean baseline); summary/diagnostics on stderr. Exit 0 ok/matched, 1 first-divergence or stream-length report, 2 usage/IO/config/log/identity/baseline-grammar errors. Bounded reads (config 1 MiB; baseline 8 MiB / 65536 lines / 64 bytes per line).
  • Tests — tests/laige-sim/replay_replay_tests.cpp (ctest -R replay_replay, 13 tests: the state-hash KAT, sensitivity matrix, convergent-world equality, zero-allocation proof; the 500-tick record → replay integration, perturbed-world divergence at the exact tick, per-field identity rejection, determinism-disabled rejection, engine round trip) + tests/replay/ (9 CTest check scripts driving the real binary: stream contract, double-run, --expect match/perturbed/truncated/malformed, identity mismatch, usage, missing log).
  • Docs — api/replay.md (execution half + hash line contract), api/entity.md, api/prng.md, api/engine.md, api/detcheck.md, api/iteration_order.md, concepts/determinism.md, getting-started/building.md (runner row), docs/README.md, src/laige-sim/README.md; laige-api.json regenerated (644 symbols); roadmap box ticked.

Verification

  • ctest -R replay_replay green (13/13).
  • Perturbed baseline: laige-replay --expect fails at the correct tick with the actionable first-divergence report (replay_expect_perturbed — tick 5; DetReplay.PerturbedDivergesAtTick7).
  • Full ctest green on all three trees: build/ 67/67, build-asan (ASan+UBSan) 67/67, build-tsan 67/67.
  • api-real-tree, include-lint-real-tree, determinism-lint-real-tree green.

Performance

No hot-path work added: the engine per-tick path is unchanged (stateHash is cold — called only by the replay/detcheck paths, once per tick there); the opt-in recording null-check cost is unchanged. The new per-tick cost exists only when actively replaying (the sim tick itself + one cold state hash).

Out of scope (next steps)

  • M1-DET-04: the detcheck CI matrix on the M1-SAMPLE-01 scenario (the scenario's hash stream switches from its ad-hoc FNV to World::stateHash with that wiring).
  • M1-DET-05: laige-replay --diff.

…y, laige-replay)

M1-DET-03 (roadmap/M1-heartbeat.md, FR-1.4/FR-11.3, TEST-004, ADR 0002,
ARCH-010, CORE-008): the replay EXECUTION half, on top of M1-DET-02's
recording and M1-ECS-05's deterministic iteration.

- src/laige-sim/include/laige/sim/entity.h + state_hash.cpp:
  World::stateHash(tick) — the deterministic 64-bit FNV-1a hash of the
  live sim state: tick, live handles (slot+generation), per non-empty
  archetype in lexicographic signature order (component ids
  ascending, row count, raw component bytes row-major in signature
  column order), per-system PRNG substream state (seed + state words).
  A pure function of the live state — convergent worlds hash
  identically (dead-slot generations, the free-list order, empty
  archetypes, the registry, capacity, presentation, timing, guardrail
  counters, and the master seed are deliberately out — the scope
  in/out lists are documented in the header). Word-stream FNV-1a 64,
  big-endian per u64 (the house convention); component bytes raw
  memory order. Cold path: O(capacity + live bytes +
  kMaxArchetypes^2), no allocation, const, no logging.
- src/laige-core/include/laige/prng.h: Prng::statePart1()/statePart2()
  read-only accessors (the substream state words that join stateHash).
- src/laige-sim/include/laige/sim/replay.h + replay.cpp:
  ReplayIdentityDiff + replayIdentityDiff (ADR 0002 enforced field by
  field — seed, tick rate, component schema hash, math backend id,
  config hash — a mismatch is a rejected replay, never silent) and
  runReplay (identity check -> determinism check -> schedule once ->
  one beginFrame()+runSystems per recorded tick; the result is the
  per-tick state-hash stream, size frameCount+1, tick 0 first).
  Structured events: replay/identity_mismatch,
  replay/determinism_disabled.
- tools/replay/laige-replay.cpp + CMakeLists.txt: the runner.
  laige-replay --log LOG --config CFG [--expect BASELINE]; stdout is
  ONLY the "<tick> <hash>" lines (16 lowercase hex; a redirect
  captures a clean baseline); summary and diagnostics on stderr.
  Exit 0 = ok/matched, 1 = first-divergence report (the baseline and
  replay values at the tick) or stream-length report, 2 = usage/IO/
  config/log/identity/baseline-grammar errors. Bounded reads (config
  1 MiB, baseline 8 MiB / 65536 lines / 64 bytes per line).
- tests/laige-sim/replay_replay_tests.cpp (ctest -R replay_replay):
  StateHash.* — the KAT, capacity/handle/component/archetype/PRNG
  sensitivity, convergent-world equality, the zero-allocation proof
  (alloc counter, LAIGE_ALLOC_COUNTER builds). DetReplay.* — the
  500-tick record -> replay integration (identical hash streams), a
  perturbed world diverging at the exact tick, the per-field identity
  rejection, the determinism-disabled rejection, and an engine round
  trip (record under run_headless; replay through two fresh engines;
  world-level twin state comparison).
- tests/replay/ (ctest -R "^replay_"): nine generated check scripts
  driving the real runner binary — smoke (stream contract),
  deterministic (double-run), --expect match / perturbed / truncated /
  malformed, identity mismatch (field report), usage error, missing
  log — asserting exit codes 0/1/2, the stdout contract, and the
  report fragments.
- docs: api/replay.md (the execution half: state hash, identity check,
  runReplay, the runner, the hash line contract, the testing),
  api/entity.md (the stateHash section), api/prng.md
  (statePart1/2), api/engine.md, api/detcheck.md,
  api/iteration_order.md, concepts/determinism.md,
  getting-started/building.md (the runner row), docs/README.md,
  src/laige-sim/README.md; laige-api.json regenerated (644 symbols);
  roadmap M1-DET-03 checked.

Verified: ctest -R replay_replay green (13 tests); the perturbed
baseline makes laige-replay --expect fail at the correct tick with
the first-divergence report (replay_expect_perturbed +
DetReplay.PerturbedDivergesAtTick7); full ctest green on all three
trees — build/ 67/67, build-asan (ASan+UBSan) 67/67, build-tsan
67/67. No hot-path work added: the engine per-tick path is unchanged
(stateHash is cold, called only by the replay/detcheck paths); the
recording null-check cost is unchanged.
@offdev
offdev merged commit eaa0705 into master Sep 16, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant