From 56f2835dc9c5183cde558a0e555ddd8ea2164590 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 16:41:32 +0200 Subject: [PATCH 1/2] [M1-DET-01] Deterministic mode + sim math rules (G-R8 trait, substreams, sim-source scan) Deterministic mode + sim math rules (FR-1.4, S-7, PRD 10.3; ARCH-010; M1-DET-01 scope, nothing else): - determinism.h (new public header, src/laige-sim/include/laige/sim/): SimMathBackend (FixedPoint16_16 default / FloatPinned32), DeterminismConfig {enabled, math}, the G-R8 trait detail::IsDeterminismSafe (integers, enums, fpx16_16, float as the fp32_pinned Scalar, the four SimMath::Vec2/Vec3; double never safe), detail::areDeterminismSafeMembers, and LAIGE_DETERMINISM_SAFE(Type, Members...) with a static_assert at the mark site. Enforced by a third static_assert in World::registerSystem folding detail::IoComponentSafety over the declared I/O (actionable message). - PRNG substreams: World::Options gains seed/deterministic; registerSystem derives Prng::deriveSubstream(seed, systemId) per system (id 0 = master, never assigned) into the SystemRecord; runSystems hands the stream to SystemContext.rng (Prng*, NSDMI) and advances it in place - the stream state IS the replay state. SystemContext stays single-arg-initializable. - EngineConfig appends seed (u64, kDefaultSimulationSeed = 0) + DeterminismConfig (existing 3-member aggregate inits keep compiling). parseEngineConfig gains seed (0..2^53 - the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted; 2^53+2 is the smallest rejectable) + the determinism object (rejections config/seed_invalid, config/determinism_invalid, config/determinism_enabled_invalid, config/determinism_math_invalid; unknown nested key -> config/unknown_key warn, ignored; first failure wins). Engine::create registers the backend-matching built-in FIRST and builds the matching PresentationSnapshot (type-erased detail::PresentationHandle - zero added allocations; headless setup stays exactly 3). engine/run_started gains seed/determinism/math fields (the laige-run CLI summary line is unchanged; --replay remains the M1-DET-02 stub). - tools/laige-determinism-lint (new; Python 3 stdlib): the sim-source scan over src/laige-sim/** - D1a float/double type tokens, D1b float literals, D1c double literals, D2 unordered_* containers, D3 malformed exception markers. A char scanner strips //, /* */ comments, string/char literals, and raw strings before matching (word-bounded, case-sensitive). Same-line '// LAIGE-DETERM-EXCEPTION: G-R8 ' markers are the documented false-positive policy; every suppressed line is counted and printed (EXC-006). Exit 0/1/2. - CI: new determinism-lint job in BOTH ci-pull.yml and ci.yml (ubuntu-24.04, python3 tools/laige-determinism-lint). - Tests: tests/laige-sim/determinism_tests.cpp (suites DeterminismMode/ DeterminismEngine/DeterminismConfigParse; ctest -R determinism_mode) - a trivial moving-entity sim produces bit-identical FNV-1a per-tick state hashes over 256 ticks in two consecutive runs (same build, same seed); a different seed diverges; substreams match Prng::deriveSubstream exactly and are independent; deterministic == false -> ctx.rng == nullptr; backend selection both ways; the full config-key table. tests/laige-sim/compile_fail/ (4 fixtures + expect-compile-result.cmake.in; ctest -R trait_compile): the positive fixture compiles; the three negatives (a double member, an unmarked user struct, a double in the mark's member list) each fail to compile with the G-R8 message (exit-code + stderr-fragment assertions). tests/tools gains the determinism-lint-* fixture tests (clean tree with one marked exception -> 0; one violation per rule -> 1; real tree -> 0). - Docs (same change, DOC-006/007): NEW docs/concepts/determinism.md (the ARCH-010 scope statement) + NEW docs/api/determinism.md; updates to docs/api/{engine,entity,system_registry,sim_math, prng}.md, docs/testing.md, docs/getting-started/building.md, docs/concepts/README.md, docs/README.md, src/laige-sim/README.md, tools/README.md. laige-api.json regenerated (573 -> 588 symbols). - Roadmap: M1-DET-01 checkbox, progress board (14/25, 34/193), change-log line. Verified: ctest 55/55 on build (Debug GCC 16.2.1), 55/55 on build-asan (ASan+UBSan leak-free), 55/55 on build-tsan (halt_on_error=1); ctest -R determinism_mode 1/1 (14 cases); ctest -R trait_compile 4/4; ctest -R determinism-lint 3/3; python3 tools/laige-include-lint OK; python3 tools/laige-determinism-lint OK (17 files, 0 violations, 15 marked exceptions); laige-api-scanner --check OK (588 symbols); CI YAML valid (determinism-lint job present in both workflows). Zero new warnings under NFR-8.10. Deviation (surfaced, not silent): declared dependency M1-CFG-01 has NOT landed - the seed/determinism keys sit on the PROVISIONAL parseEngineConfig surface (documented as provisional in engine.md, the header preamble, and the change-log line); M1-CFG-01 owns the final versioned schema. --- .github/workflows/ci-pull.yml | 19 + .github/workflows/ci.yml | 25 +- docs/README.md | 35 +- docs/api/determinism.md | 171 +++++ docs/api/engine.md | 107 ++- docs/api/entity.md | 12 + docs/api/prng.md | 12 + docs/api/sim_math.md | 9 + docs/api/system_registry.md | 26 + docs/concepts/README.md | 21 +- docs/concepts/determinism.md | 231 ++++++ docs/getting-started/building.md | 14 + docs/testing.md | 40 +- laige-api.json | 282 ++++---- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 5 +- src/laige-sim/README.md | 26 +- src/laige-sim/engine.cpp | 189 ++++- src/laige-sim/entity.cpp | 19 + src/laige-sim/include/laige/sim/determinism.h | 301 ++++++++ src/laige-sim/include/laige/sim/engine.h | 228 +++++- src/laige-sim/include/laige/sim/entity.h | 55 +- .../include/laige/sim/presentation.h | 16 +- src/laige-sim/include/laige/sim/system.h | 85 ++- src/laige-sim/system_timing.cpp | 10 +- src/laige-sim/systems.cpp | 16 +- tests/laige-sim/CMakeLists.txt | 80 ++- tests/laige-sim/compile_fail/trait_ok.cpp | 46 ++ .../compile_fail/trait_reject_bad_mark.cpp | 44 ++ .../compile_fail/trait_reject_double.cpp | 39 ++ .../compile_fail/trait_reject_unmarked.cpp | 42 ++ tests/laige-sim/determinism_tests.cpp | 661 ++++++++++++++++++ .../laige-sim/expect-compile-result.cmake.in | 48 ++ tests/laige-sim/scheduler_tests.cpp | 6 + tests/laige-sim/system_registry_tests.cpp | 6 + tests/laige-sim/system_timing_tests.cpp | 2 + tests/tools/CMakeLists.txt | 83 +++ tests/tools/expect-det-lint-result.cmake.in | 35 + tools/README.md | 13 + tools/laige-determinism-lint | 387 ++++++++++ 40 files changed, 3170 insertions(+), 278 deletions(-) create mode 100644 docs/api/determinism.md create mode 100644 docs/concepts/determinism.md create mode 100644 src/laige-sim/include/laige/sim/determinism.h create mode 100644 tests/laige-sim/compile_fail/trait_ok.cpp create mode 100644 tests/laige-sim/compile_fail/trait_reject_bad_mark.cpp create mode 100644 tests/laige-sim/compile_fail/trait_reject_double.cpp create mode 100644 tests/laige-sim/compile_fail/trait_reject_unmarked.cpp create mode 100644 tests/laige-sim/determinism_tests.cpp create mode 100644 tests/laige-sim/expect-compile-result.cmake.in create mode 100644 tests/tools/expect-det-lint-result.cmake.in create mode 100755 tools/laige-determinism-lint diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 5519090..3203b11 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -243,6 +243,25 @@ jobs: - name: Lint include graph + report dependency count run: python3 tools/laige-include-lint + determinism-lint: + # M1-DET-01: runs on every PR (no ci:* condition). The sim-source + # determinism scan (the second half of the G-R8 guarantee; the + # first is the compile-time trait checked by the + # trait_compile_* CTest fixtures): forbids raw float/double and + # unordered containers in src/laige-sim/**, with per-line + # LAIGE-DETERM-EXCEPTION markers as the documented false-positive + # policy (docs/concepts/determinism.md). Platform-independent: + # Python 3 stdlib only, no setup step. The CTest suite runs it + # against fixture trees and the real tree in every P0 job as well + # (tests/tools, tests `determinism-lint-*`). + name: Determinism source scan (sim module) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Scan sim sources for raw FP / unordered containers + run: python3 tools/laige-determinism-lint + api-manifest: # M0-TOOL-01: runs on every PR (no ci:* condition). The checked-in # public API manifest (laige-api.json, PRD §9.4, NFR-13.1) must stay diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2502bab..810661a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,9 @@ # ALL ten jobs run (the five P0 OS/variant jobs, the two sanitizer # lanes (M0-CI-02), and the three platform-independent tooling jobs: # the include-graph lint with dependency-count metric (M0-CI-03), -# the public API manifest drift check (M0-TOOL-01), and the -# determinism check (M0-TOOL-02)). +# the public API manifest drift check (M0-TOOL-01), the +# determinism source scan (M1-DET-01), and the determinism check +# (M0-TOOL-02)). # # Fuzz lane (PRD §14 "every commit (bounded)"; M0-TEST-01): there is no # separate fuzz job — the `fuzz_json_parse` ctest entry (1000 @@ -34,6 +35,7 @@ # macos-intel macOS Intel, AppleClang (macos-14) # include-lint Include-graph lint + dependency count (M0-CI-03) # api-manifest Public API manifest drift check (M0-TOOL-01) +# det-lint Determinism source scan (M1-DET-01) # detcheck Determinism check (M0-TOOL-02) # # The include-lint job (M0-CI-03; NFR-8.11, NFR-8.13) is platform- @@ -259,6 +261,25 @@ jobs: - name: Lint include graph + report dependency count run: python3 tools/laige-include-lint + determinism-lint: + # M1-DET-01: platform-independent repository check — the sim-source + # determinism scan (the second half of the G-R8 guarantee; the first + # is the compile-time trait checked by the trait_compile_* CTest + # fixtures). Forbids raw float/double and unordered containers in + # src/laige-sim/**, with per-line LAIGE-DETERM-EXCEPTION markers as + # the documented false-positive policy (docs/concepts/determinism.md). + # Python 3 stdlib only, so one runner image suffices; it runs on + # every PR too (ci-pull.yml, same job definition). The CTest suite + # runs it against fixture trees and the real tree in every P0 job as + # well (tests/tools, tests `determinism-lint-*`). + name: Determinism source scan (sim module) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Scan sim sources for raw FP / unordered containers + run: python3 tools/laige-determinism-lint + api-manifest: # M0-TOOL-01 (PRD §9.4, NFR-13.1): the checked-in public API manifest # (laige-api.json) must stay in sync with the public headers. This diff --git a/docs/README.md b/docs/README.md index 9f0c6dd..bbcb6ed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,10 @@ system scheduler; M1-SYS-03: the per-system timing + budget enforcement; M1-LOOP-01: the fixed-timestep game loop core; M1-LOOP-02: the per-tick presentation snapshot + interpolation state; M1-HEAD-01: the headless engine run — `Engine` -(config → world → systems → loop) and the `laige-run` binary). +(config → world → systems → loop) and the `laige-run` binary; +M1-DET-01: deterministic mode — the SimMath-only sim guarantee +(the G-R8 compile-time trait + the CI source scan), the per-system +PRNG substreams, and the `seed`/`determinism` config keys). Every section of the AGENTS §13 `docs/` tree exists; each entry below links what is written and the "not yet written" section marks what is still to land. @@ -29,11 +32,11 @@ still to land. ## Concepts - [Concepts index](concepts/README.md) — architecture, coordinates - (ARCH-008), lifecycle, threading, and determinism scope. **Not yet - written** (M0 is foundations only); the index names each planned - document and its interim home today (the `Vec2`/`Vec3` comments in - `src/laige-core/include/laige/sim_math.h`, ADR 0002, the per-API - contracts). + (ARCH-008), lifecycle, threading, and determinism scope. + [Determinism](concepts/determinism.md) is written (M1-DET-01: the + same-build scope, the two-layer G-R8 enforcement, the exception + policy, the PRNG substreams); the other topics name their planned + document and interim home. ## API contracts (per public header) @@ -89,8 +92,14 @@ still to land. - [Headless engine run](api/engine.md) — `laige::Engine` (config → world → systems → loop): `run_headless(maxTicks)` the bounded + server run forms, the ordered idempotent CONC-006 - shutdown, the provisional config surface, and the `laige-run` - CLI (M1-HEAD-01; `laige-sim` + `tools/run`). + shutdown, the provisional config surface (now including the + `seed`/`determinism` keys, M1-DET-01), the backend selection at + init, and the `laige-run` CLI (M1-HEAD-01; `laige-sim` + `tools/run`). +- [Determinism-safe storage](api/determinism.md) — the G-R8 + compile-time trait: `SimMathBackend`, `DeterminismConfig`, + `detail::IsDeterminismSafe`, and + `LAIGE_DETERMINISM_SAFE(Type, MemberTypes...)` (M1-DET-01; + `laige-sim`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, @@ -160,9 +169,10 @@ still to land. ## Not yet written (honest status) -- `concepts/` — the architecture, coordinates, lifecycle, threading, and - determinism concept documents (the [index](concepts/README.md) names - each and its interim home). +- `concepts/` — the architecture, coordinates, lifecycle, and + threading concept documents (the [index](concepts/README.md) names + each and its interim home). [Determinism](concepts/determinism.md) + is written (M1-DET-01). - `guides/` — task-oriented usage (first game, profiling, determinism) — see the [index](guides/README.md). - `debugging/` — the in-engine debug mode (AGENTS §15; profiling @@ -185,7 +195,8 @@ still to land. [system_timing.md](api/system_timing.md), [game_loop.md](api/game_loop.md), [presentation.md](api/presentation.md), - [engine.md](api/engine.md).) + [engine.md](api/engine.md), + [determinism.md](api/determinism.md).) ## Related diff --git a/docs/api/determinism.md b/docs/api/determinism.md new file mode 100644 index 0000000..9ead9f3 --- /dev/null +++ b/docs/api/determinism.md @@ -0,0 +1,171 @@ +# Determinism-safe storage (`laige/sim/determinism.h`) + +The compile-time half of the G-R8 determinism guarantee (M1-DET-01; +PRD §10.3, AGENTS ARCH-010/S-7/G-R8, ADR 0002). Public header: +`src/laige-sim/include/laige/sim/determinism.h` (header-only; no +implementation file). The concept-level contract — what is +deterministic, at what scope, enforced how — is +[concepts/determinism.md](../concepts/determinism.md); this document +covers the API surface. + +## Quick start + +```cpp +#include + +struct Health { + std::int32_t current{}; + std::int32_t max{}; +}; +LAIGE_COMPONENT(Health); +// The member list IS the type's storage: verified at this site. +LAIGE_DETERMINISM_SAFE(Health, std::int32_t, std::int32_t); + +// Now a system may declare I/O for Health: the G-R8 static_assert in +// World::registerSystem accepts it. +world.registerSystem(MySystem_Def, + laige::Io{}); +``` + +## The API + +`laige::SimMathBackend` (enum class, `uint8_t`) — the backend ids the +config surface names: + +| Value | Config id string | Backend | +|---|---|---| +| `FixedPoint16_16` | `"fixed_point_16_16"` | Q16.16, the default; bit-exact across build/platform/ISA/compiler (ADR 0002). | +| `FloatPinned32` | `"float_pinned_32"` | IEEE `float`, pinned flags; same-build/same-ISA scope (ADR 0002). | + +`laige::DeterminismConfig` — the engine's determinism block: + +| Member | Default | Contract | +|---|---|---| +| `bool enabled` | `true` | Deterministic mode: SimMath-only sim, per-system PRNG substreams, replay identity (seed + backend + config + inputs). | +| `SimMathBackend math` | `FixedPoint16_16` | The selected SimMath backend (compile-time dispatch at engine init; part of replay identity). | + +`laige::detail::IsDeterminismSafe` (trait) — true when `T` is +determinism-safe storage: + +| `T` | Safe | +|---|---| +| Any integer type (`int8_t` … `uint64_t`) | yes | +| Any enum type | yes | +| `laige::fpx16_16` | yes | +| `float` | yes — the `fp32_pinned` backend's registered `Scalar` (ADR 0002). `float` is the *backend type*, not "raw float in the sim": using it is legal only through SimMath-registered types (this scalar or a `SimMath` vector). | +| `SimMath::Vec2/Vec3` | yes | +| `SimMath::Vec2/Vec3` | yes | +| `double` | **no** — no SimMath backend uses it; never determinism-safe. | +| Any other type (unmarked struct, `std::string`, …) | no (primary template is false). | + +`LAIGE_DETERMINISM_SAFE(Type, MemberTypes...)` — the mark: + +- Declares that `Type`'s members are **exactly** the listed member types + (every member; order is irrelevant — the list is a set of types). +- Specializes `IsDeterminismSafe` with the verified member list: + `value = areDeterminismSafeMembers()` (the &&-fold; + an empty list is vacuously safe). +- **Fails at the mark site** (a `static_assert` in the specialization): a + non-safe type in the list — e.g. a `double` member — is a compile + error there, before any system can declare the component in its I/O. + The error names the mark and points here. +- Write it once per type, at namespace scope, next to the type + definition (the `LAIGE_COMPONENT` precedent). A type that is itself an + integer, an enum, or a SimMath-registered scalar/vector needs **no** + mark. A member type that is itself a user struct must be marked in + turn (recursion). + +`laige::detail::areDeterminismSafeMembers()` — the &&-fold the +mark expands to (true when every listed type is safe). + +`laige::detail::IoComponentSafety` — the fold helper +`World::registerSystem` uses: `Io` → +`IsDeterminismSafe::value`; non-`Io` tags are vacuously true (the +`IsIoTag` static_assert fires first, so they never reach this fold). + +## Where it is enforced + +1. **The mark site** — `LAIGE_DETERMINISM_SAFE` fails fast on a bad + member list (compile error at the declaration). +2. **`World::registerSystem`** (entity.h) — the third `static_assert` + folds `IoComponentSafety` over the system's declared I/O: a component + that is not determinism-safe fails with an actionable message (mark + the component, or change the storage; points here). The check is over + *declared* I/O — a system that writes a `double` through some other + path is caught by the source scan below, not the trait. +3. **`tools/laige-determinism-lint`** — the textual scan of every + `src/laige-sim/**` translation unit (raw `float`/`double` type tokens, + float/double literals, `unordered_*` containers), with same-line + `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers as the documented + false-positive policy. CI job `determinism-lint` (both workflows) + + ctest `determinism-lint-*`. + +The two layers are complementary by design: the trait covers *component +storage* (what a system's I/O names); the scan covers *sim translation +units* (what the code does). Neither subsumes the other. + +## Determinism scope (ARCH-010) + +This header defines *storage safety*, not the determinism scope itself. +The scope statement lives in +[concepts/determinism.md](../concepts/determinism.md): same-build +bit-identity (verified), the per-backend scopes of ADR 0002, and the +cross-target work left to M1-DET-04. + +## Performance (DOC-004) + +Everything here is `constexpr` template metaprogramming evaluated at +compile time: zero runtime cost, zero allocations, no state. The +`static_assert`s cost compile time only (one fold per +`registerSystem` call site; the mark's check is one &&-fold at the mark +site). + +## Misuse warnings + +- **An unmarked user struct is never safe** — "all my members are ints" + is not the declaration; the mark is. The trait's primary template is + false on purpose (fail-closed). +- **The member list must be complete.** Listing a subset of the members + claims the type has no other members; a missing non-safe member is a + lie the next `registerSystem` will not catch (the mark already + passed). Keep the list in sync with the struct (same-file, next to + it). +- **`float` in a mark means the fp32_pinned backend's Scalar.** Storing + raw `float` in a component that is meant to run under `fpx16_16` is a + backend mismatch, not a G-R8 violation — use `SimMath` + types for backend-independent sim state. +- **`double` is not "almost safe".** It has no backend; there is no + mode where it is legal in sim storage. + +## Verification + +- `ctest -R trait_compile` — the compile-check fixtures + (`tests/laige-sim/compile_fail/`): `trait_compile_ok` (a marked safe + component compiles), `trait_compile_reject_double` (a `double` member + fails), `trait_compile_reject_unmarked` (an unmarked struct fails), + `trait_compile_reject_bad_mark` (a `double` in the mark's member list + fails at the mark site). +- `ctest -R determinism_mode` — `determinism_tests.cpp`: the + DeterminismMode / DeterminismEngine / DeterminismConfigParse suites + (same-seed identical 256-tick hash streams, seed divergence, substream + golden cross-check + independence, disabled-mode null rng, backend + selection, the config keys). +- `ctest -R determinism-lint` + CI `determinism-lint` — the source scan + (fixtures + real tree). + +## Related + +- [concepts/determinism.md](../concepts/determinism.md) — the scope and + the two-layer enforcement. +- [ADR 0002](../decisions/0002-deterministic-math.md) — SimMath, the two + backends, replay identity. +- [api/sim_math.md](sim_math.md) — the SimMath op surface (the only math + allowed in deterministic systems). +- [api/prng.md](prng.md) — `laige::Prng` substream derivation (the + per-system streams `SystemContext.rng` points at). +- [api/engine.md](engine.md) — the `seed`/`determinism` config keys and + the backend selection at init. +- [api/entity.md](entity.md) — `World::Options.seed`/`deterministic` and + the `registerSystem` G-R8 static_assert. +- [api/system_registry.md](system_registry.md) — `SystemContext.rng` and + the per-system substreams. diff --git a/docs/api/engine.md b/docs/api/engine.md index c1a84da..20e06c7 100644 --- a/docs/api/engine.md +++ b/docs/api/engine.md @@ -37,11 +37,13 @@ const laige::Status status = engine.run_headless(10'000); 1. **`Engine::create(config)`** — validates the config, creates the `World` (capacity = `entityCapacity`, churn budget = - `churnPerFrameBudget`), and registers the built-in - `sim::Position2DFpx16` component **first** (ARCH-010: stable - component-type ordering; the ADR 0002 default SimMath backend, - `fpx16_16`). No frame is run and no loop exists yet; the engine is - in the *not started* state. + `churnPerFrameBudget`, seed = `seed`, deterministic mode = + `determinism.enabled`), and registers the built-in component + matching the configured SimMath backend **first** (ARCH-010: + stable component-type ordering): `sim::Position2DFpx16` for + `fpx16_16` (the ADR 0002 default) or `sim::Position2DFp32` for + `fp32_pinned`. No frame is run and no loop exists yet; the engine + is in the *not started* state. 2. **Game registration** — the game registers its components and systems on `engine.world()` before the run (see Misuse warnings). 3. **`run_headless(maxTicks)`** — computes the system schedule, @@ -68,18 +70,42 @@ engine in the process). ## The config surface (provisional) -`EngineConfig{tickRateHz, entityCapacity, churnPerFrameBudget}` and -`parseEngineConfig(const JsonValue&)` are the **provisional** config -surface for M1-HEAD-01. M1-CFG-01 owns the final versioned config -schema (PRD §10, ARCH-007: persistent data MUST be versioned); when -M1-CFG-01 lands, the JSON parse moves behind its versioned reader and -this surface is folded into it. The provisional keys: +`EngineConfig{tickRateHz, entityCapacity, churnPerFrameBudget, seed, +determinism}` and `parseEngineConfig(const JsonValue&)` are the +**provisional** config surface for M1-HEAD-01. M1-CFG-01 owns the final +versioned config schema (PRD §10, ARCH-007: persistent data MUST be +versioned); when M1-CFG-01 lands, the JSON parse moves behind its +versioned reader and this surface is folded into it. The provisional +keys: | key | type | range | default | |---|---|---|---| | `tick_rate_hz` | exact integer | 20–120 | `kDefaultTickRateHz` (60) | | `entity_budget` | exact integer | 0–65536 | `0` (an empty scene — a valid world that creates no entities; entity creation on it fails `BudgetExhausted`) | | `churn_per_frame_budget` | exact integer | 0–4294967295 | `kDefaultChurnPerFrameBudget` (256) | +| `seed` | exact integer | 0–2^53 (JSON) / 0–2^64−1 (struct) | `kDefaultSimulationSeed` (0) | +| `determinism` | object (below) | — | `{enabled: true, math: "fixed_point_16_16"}` | + +The `determinism` object (M1-DET-01; see +[concepts/determinism.md](../concepts/determinism.md) for the scope +and [api/determinism.md](determinism.md) for the types): + +| nested key | type | range | default | +|---|---|---|---| +| `determinism.enabled` | bool | — | `true` | +| `determinism.math` | string | `"fixed_point_16_16"` \| `"float_pinned_32"` | `"fixed_point_16_16"` | + +- The **seed is part of replay identity** (ADR 0002) and is logged on + `engine/run_started`. In JSON it is bounded to `2^53` because ADR + 0003 stores numbers as doubles (exact to 2^53); the programmatic + `EngineConfig.seed` is the full `uint64_t`. A seed above the JSON + bound, a non-integer, or a negative is rejected + (`config/seed_invalid`). +- `enabled` selects deterministic mode (per-system PRNG substreams, + the replay promise); `false` is the documented escape hatch + (no substreams, `SystemContext.rng == nullptr`). `math` selects the + SimMath backend the engine registers (the built-in component and the + presentation snapshot). - **Unknown keys** are ignored with one rate-limited `config/unknown_key` warn per key (forward-compatible with @@ -87,9 +113,16 @@ this surface is folded into it. The provisional keys: - **Rejections** (first failure wins, one rate-limited warn each): `config/not_an_object` (document is not a JSON object), `config/tick_rate_invalid` (absent/out of range/non-integer), - `config/entity_budget_invalid`, `config/churn_budget_invalid` — + `config/entity_budget_invalid`, `config/churn_budget_invalid`, + `config/seed_invalid` (non-integer / out of range / above the 2^53 + JSON bound / wrong type), `config/determinism_invalid` (not an + object), `config/determinism_enabled_invalid` (not a bool), + `config/determinism_math_invalid` (not one of the two backend ids) — each maps to `ErrorCode::InvalidArgument` (NFR-13.3 grammar: - `{codeId}|{what}|{why}|{fix}|{docAnchor}`, see `errors.md`). + `{codeId}|{what}|{why}|{fix}|{docAnchor}`, see `errors.md`). An + **unknown key inside `determinism`** is not a rejection: it warns + (`config/unknown_key`) and is ignored, like the top-level unknown-key + rule (forward-compat with M1-CFG-01). - `Engine::create` re-validates the `EngineConfig` struct itself (the struct is public; the JSON path is not the only constructor), so a hand-built out-of-range config is rejected identically. @@ -120,11 +153,13 @@ value the engine consumes. clock — the `laige_run_smoke` ctest budget (TIMEOUT 300) and its `status=ok` assertion (CI asserts the run completed, not the tick count; drops are the documented overload behavior). -- **Lifecycle logs** — one `engine/run_started` (Info) before setup, - one `engine/run_finished` (Info) after the last frame with the - final accounting (`ticks`, `dropped_ticks`, `dropped_frames`, - `status`); both are structured, stable, and machine-greppable - (AGENTS §14). +- **Lifecycle logs** — one `engine/run_started` (Info) before setup + (fields `tick_rate_hz`, `tick_target`, `frame_budget_ticks`, + `seed`, `determinism`, `math` — the math field is the backend id + string `fpx16_16` or `fp32_pinned`), one `engine/run_finished` + (Info) after the last frame with the final accounting (`ticks`, + `dropped_ticks`, `dropped_frames`, `status`); both are structured, + stable, and machine-greppable (AGENTS §14). **Failure behavior** (the run always ends in shutdown): @@ -138,10 +173,14 @@ value the engine consumes. ## Presentation wiring (ARCH-009) -The engine owns a -`PresentationSnapshot` — the **default** SimMath -backend per ADR 0002 (determinism math selection is M1-DET-01's -decision; the engine does not expose a backend knob in M1). Wiring: +The engine owns a `PresentationSnapshot` for the **configured** +SimMath backend `B` (ADR 0002, M1-DET-01): `sim::Fpx16_16` for the +default `fpx16_16`, `sim::Fp32Pinned` for `fp32_pinned` — the same +backend the engine registered as the built-in component at init, so +the snapshot and the sim agree. The handle is type-erased on the +engine (`detail::PresentationHandle`); the backend is fixed at +`create` and is part of replay identity (a replay must use the same +backend — ADR 0002). Wiring: - The loop is created with the engine's per-tick hook from the first `frame()`, so the snapshot exists before the hook can fire (the @@ -158,13 +197,23 @@ decision; the engine does not expose a backend knob in M1). Wiring: ## Determinism scope (ARCH-010) -Headless runs are deterministic **within the same build, platform, -architecture, and compiler**: the tick cadence is integer arithmetic, -the system order is the validated schedule, and the SimMath backend -is pinned (`fpx16_16`). Wall-clock pacing (the sleep) does **not** -enter the simulation — it only decides when frames run; dropped ticks -are the documented, logged overload behavior, not nondeterminism. -Cross-build/platform determinism, replay, and state hashing are +Headless runs in deterministic mode (the default) are +**bit-identical within the same build, platform, architecture, and +compiler**: the tick cadence is integer arithmetic, the system order +is the validated schedule, the SimMath backend is the configured one +(`fpx16_16` by default — bit-exact by the language standard, ADR +0002), and any randomness is a named input (the per-system PRNG +substreams derived from `seed`, a fixed call order). Wall-clock +pacing (the sleep) does **not** enter the simulation — it only +decides when frames run; dropped ticks are the documented, logged +overload behavior, not nondeterminism. The same-build guarantee is +verified by `ctest -R determinism_mode` (the +`DeterminismMode.*` suites: identical 256-tick state-hash streams in +two consecutive runs; seed divergence). The full scope statement — +what is deterministic, the per-backend scopes, what is not yet — is +[concepts/determinism.md](../concepts/determinism.md). +Cross-build/platform determinism is **M1-DET-04** (the detcheck +matrix); replay execution and state hashing of full input streams is **M1-DET-02** (the `--replay` flag is its stub today). ## `laige-run` (the CLI) diff --git a/docs/api/entity.md b/docs/api/entity.md index e5bb653..78e9fa1 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -62,6 +62,18 @@ a moved world keeps its component data); a moved-from world is a valid empty world (capacity 0: every `create()` fails, every handle invalid). Not copyable. +`World::Options` (M1-DET-01) carries the determinism settings: + +| Field | Default | Contract | +|---|---|---| +| `capacity` | 0 | The scene budget (G-R3); `> Entity::kMaxEntities` → `InvalidArgument`. | +| `churnPerFrameBudget` | `kDefaultChurnPerFrameBudget` | The G-R4 per-frame component-churn budget. | +| `seed` | 0 | The master PRNG seed; part of replay identity (ADR 0002). Each system registered in deterministic mode gets a substream derived from it (`Prng::deriveSubstream(seed, systemId)`). | +| `deterministic` | true | When true, `registerSystem` creates each system's PRNG substream (`SystemContext.rng` is non-null) and the G-R8 static_assert applies. When false, no substreams are created (`SystemContext.rng == nullptr`) — the documented escape hatch for non-deterministic prototypes. | + +The engine (`engine.md`) forwards `EngineConfig.seed` and +`EngineConfig.determinism.enabled` to these fields. + M1-ECS-03 adds the component layer on the same slot tables: `world.has(e)`, `world.get(e)`, `world.addComponent(e, v)`, `world.removeComponent(e)`, `world.archetypeCount()`, diff --git a/docs/api/prng.md b/docs/api/prng.md index 732b87c..39e7a43 100644 --- a/docs/api/prng.md +++ b/docs/api/prng.md @@ -166,6 +166,18 @@ draw count)`. For bit-exact restoration of an in-flight stream, save `Prng(seed)` advanced to the saved state) — `stepState` is public exactly for this and for the period proof's reconstruction of the state map. +**Sim systems' substreams (M1-DET-01):** in deterministic mode (the +default), `World::registerSystem` derives each system's stream from +`(World::Options::seed, the system's dense registration id)` — id 0 is +the master seed and is never assigned to a system — and hands it to the +system as `SystemContext::rng` (non-null in deterministic mode, +`nullptr` when disabled; see +[api/system_registry.md](system_registry.md)). The stream is advanced +in place during the system's draws, so the registry's stream state is +the replay state (its inclusion in the state hash lands with +M1-DET-03). Scope and guarantees: +[concepts/determinism.md](../concepts/determinism.md). + ## Misuse warnings - **Copying to "share" a substream interleaves the copies' draws.** One diff --git a/docs/api/sim_math.md b/docs/api/sim_math.md index 6bed3c0..5fba4b4 100644 --- a/docs/api/sim_math.md +++ b/docs/api/sim_math.md @@ -203,6 +203,15 @@ loudly if the flags are ever missing — verified by a negative build with per-platform support list; any desyncing pair is declared unsupported for this backend. +**Enforcement (G-R8, M1-DET-01):** SimMath ops are the *only* math +allowed in deterministic sim systems. Raw `float`/`double` and +platform intrinsics outside SimMath are forbidden, enforced at two +layers: the compile-time trait in `World::registerSystem` (component +storage must be determinism-safe — +[api/determinism.md](determinism.md)) and the CI source scan +`tools/laige-determinism-lint` (sim translation units). The full scope +statement is [concepts/determinism.md](../concepts/determinism.md). + ## Performance (DOC-004) - **Complexity:** every op is O(1); no loops, no recursion (`fpx16_16` diff --git a/docs/api/system_registry.md b/docs/api/system_registry.md index 55c7692..40558b6 100644 --- a/docs/api/system_registry.md +++ b/docs/api/system_registry.md @@ -51,6 +51,18 @@ inheritance, no state object. The function plus its `SystemDef` [query.md](query.md)). The context is built per system per tick by the scheduler (M1-SYS-02, [scheduler.md](scheduler.md)); never store it across ticks. +- `ctx.rng` — the system's own PRNG substream (M1-DET-01): a + `laige::Prng*` derived from the world's seed and this system's + registration id (`Prng::deriveSubstream(seed, id)`; id 0 is the + master, never assigned to a system). Non-null when the world was + created in deterministic mode (the default), null when it was not + (the documented escape hatch). The stream is advanced in place as + the system draws — its state is part of the replay state, so a + draw belongs at a fixed position in the system's run (e.g. before + its iteration). Drawing is optional: a system that never touches + `ctx.rng` costs nothing. See + [concepts/determinism.md](../concepts/determinism.md) for the + substream contract and [api/prng.md](prng.md) for the taps. - Systems are deterministic when the engine runs in deterministic mode (M1-DET-01) and must stay within their declared budget (M1-SYS-03 measures per-system time and enforces the budget — @@ -143,6 +155,7 @@ rate-limited structured warn (subsystem `system`, LOG-004) plus a | malformed `depends_on` spec (empty token, duplicate name, more than `kMaxSystemDependencies`) | `InvalidArgument` + warn | `system/dep_spec_invalid` | | duplicate name in this world | `InvalidArgument` + warn | `system/duplicate` | | `Io`: `T` not a Laige component | compile error | — | +| `Io`: `T` not determinism-safe (G-R8, M1-DET-01) | compile error (a `static_assert` in `registerSystem`) | — | | `Io`: `T` not registered (this world) | `InvalidArgument` + warn | `system/io_unregistered` | | same component declared twice (any access) | `InvalidArgument` + warn | `system/io_duplicate` | | more than `kMaxSystems` systems | `BudgetExhausted` + warn | `system/budget_exhausted` | @@ -151,6 +164,19 @@ The `budget_raw` log field is the rejected budget in Q16.16 raw units (value = raw / 2^16 ms, ADR 0002); `existing_system_id` / `component_id` identify the conflicting registrations. +The G-R8 `static_assert` (M1-DET-01) is a compile-time check over the +system's declared I/O: every `Io` component must be +determinism-safe storage — integers, enums, `fpx16_16`, `float`, a +`SimMath::Vec2/Vec3`, or a user struct marked +`LAIGE_DETERMINISM_SAFE(T, Members...)` (a `double` member is never +legal; no SimMath backend uses it). The check fails with an actionable +message naming the fix and pointing at +[api/determinism.md](determinism.md); it runs at the call site, before +any runtime validation. See +[concepts/determinism.md](../concepts/determinism.md) for the +two-layer enforcement (this trait is the compile-time half; the +`determinism-lint` CI job is the source half). + ## Performance (DOC-004) - **Registration (setup path):** O(n) in the number of registered diff --git a/docs/concepts/README.md b/docs/concepts/README.md index 4d2056b..0c0803c 100644 --- a/docs/concepts/README.md +++ b/docs/concepts/README.md @@ -1,18 +1,17 @@ # Concepts Architecture, coordinates, lifecycle, and threading concepts -(AGENTS §13). **Nothing in this section is written yet** — M0 is -foundations only. The topics below are planned, and each document lands -with the milestone that defines it; until then the interim homes are: +(AGENTS §13). The topics below land with the milestone that defines +them; until then the interim homes apply: -| Topic | Planned document | Interim home (today) | +| Topic | Document | Status | |---|---|---| -| World axes, handedness, units, depth convention, render ordering, conversion rules (ARCH-008) | `coordinates.md` | The `Vec2`/`Vec3` comments in `src/laige-core/include/laige/sim_math.h` ((x, y) is the ground plane, z is depth/height) and the SimMath API contract in [api/sim_math.md](../api/sim_math.md) | -| Determinism scope (ARCH-010) | `determinism.md` | [ADR 0002](../decisions/0002-deterministic-math.md) (which paths use which backend), [api/sim_math.md](../api/sim_math.md) (NaN/Inf policy, pinned flags), [api/detcheck.md](../api/detcheck.md) (replay comparison contract) | -| Engine / scene / entity lifecycle; the fixed-timestep rule (ARCH-002) | `lifecycle.md` | — (the M1 loop steps define it) | -| Threading and ownership model (CONC-001…CONC-007) | `threading.md` | The per-API contracts in [api/](../api/) — each document states its threading, lifetime, and phase rules | -| Module architecture (PRD §10.1 stack) | `architecture.md` | The PRD §10.1 module map, enforced by the include-graph lint (`tools/laige-include-lint`, M0-CI-03) | +| Determinism scope (ARCH-010, S-7, G-R8) | [determinism.md](determinism.md) | **Written** (M1-DET-01): the same-build guarantee, the two-layer G-R8 enforcement (the compile-time trait + the CI source scan), the exception policy, the PRNG substreams, the config surface | +| World axes, handedness, units, depth convention, render ordering, conversion rules (ARCH-008) | `coordinates.md` | Not yet written — interim home: the `Vec2`/`Vec3` comments in `src/laige-core/include/laige/sim_math.h` ((x, y) is the ground plane, z is depth/height) and the SimMath API contract in [api/sim_math.md](../api/sim_math.md) | +| Engine / scene / entity lifecycle; the fixed-timestep rule (ARCH-002) | `lifecycle.md` | Not yet written (the M1 loop steps define it) | +| Threading and ownership model (CONC-001…CONC-007) | `threading.md` | Not yet written — interim home: the per-API contracts in [api/](../api/) — each document states its threading, lifetime, and phase rules | +| Module architecture (PRD §10.1 stack) | `architecture.md` | Not yet written — interim home: the PRD §10.1 module map, enforced by the include-graph lint (`tools/laige-include-lint`, M0-CI-03) | Normative decisions about these topics live as ADRs in -[../decisions/](../decisions/README.md); this section will explain the -chosen designs once they exist. +[../decisions/](../decisions/README.md); this section explains the +chosen designs as they exist. diff --git a/docs/concepts/determinism.md b/docs/concepts/determinism.md new file mode 100644 index 0000000..3fe0418 --- /dev/null +++ b/docs/concepts/determinism.md @@ -0,0 +1,231 @@ +# Determinism (ARCH-010, S-7, G-R8, ADR 0002) + +The engine's determinism contract, stated at the scope ARCH-010 +requires: **what is deterministic, under which build, verified how, and +what it is not**. This document is the normative home for the M1-DET-01 +step (roadmap/M1-heartbeat.md); the strategy itself — two SimMath +backends, one op surface — is [ADR 0002](../decisions/0002-deterministic-math.md). + +## The guarantee (scope) + +Deterministic mode (`EngineConfig.determinism.enabled == true`, the +default) guarantees: + +- **Bit-identical simulation state, run after run, on the same build.** + The same config, the same seed, the same inputs, run twice in the same + build (same compiler, platform, architecture) produce identical + per-tick state — verified by + `determinism_tests.cpp` (`DeterminismMode.*`): a trivial moving-entity + sim produces identical FNV-1a per-tick state hashes over 256 ticks in + two consecutive runs (the machine-greppable + `determinism-tick-stream` line lands in the ctest output). +- **The guarantee is per-build by construction of the default backend.** + `fpx16_16` (Q16.16 integer arithmetic) is bit-identical across build, + platform, ISA, and compiler by the C++20 language standard — no flag + archaeology. `fp32_pinned` is bit-identical across runs of the same + build on the same platform/ISA; its cross-ISA scope is *not* promised + until the detcheck matrix proves it (M1-DET-04). ADR 0002 states both + scopes in its decision table; this document is where the promised + scope lives once the tests exist. +- **The seed is part of replay identity.** A different seed diverges + (`DeterminismMode.DifferentSeedDiverges`): replay identity is + inputs + seed + math backend + config (ADR 0002). +- **The math backend is part of replay identity.** Cross-backend replays + are not bit-exact and are not supported (ADR 0002): a replay recorded + under `fpx16_16` must be replayed under `fpx16_16`. + +**What it is not (yet):** + +- Cross-compile / cross-platform bit-identity is not part of this step's + verification — it is M1-DET-04's detcheck matrix (two build + configurations, both backends). +- `fp32_pinned` cross-ISA determinism is not promised; any desyncing CI + pair is declared unsupported for that backend (ADR 0002 review + conditions). +- Replay *execution* (feeding a recorded input stream back through the + sim) is M1-DET-02; the `laige-run --replay` flag is a stub until then. +- PRNG *state introspection* (reading a substream's state words) is + M1-DET-03; `laige::Prng` deliberately has no state getters. + +## Why only SimMath ops (G-R8) + +In deterministic systems, **raw `float`/`double` and platform +intrinsics outside SimMath are forbidden** (PRD §10.3, S-7, G-R8): the +two reasons are (a) FMA contraction, reassociation, and per-compiler +defaults silently change IEEE results between builds, and (b) +`double` has no SimMath backend at all — it is never determinism-safe +storage (ADR 0002). The ban is enforced at two independent layers that +cover each other's blind spots: + +1. **Compile-time trait (components).** Every component a system + declares I/O for must be *determinism-safe* storage, checked by a + `static_assert` in `World::registerSystem` (entity.h). The mechanism + is in [laige/sim/determinism.h](../../src/laige-sim/include/laige/sim/determinism.h) + (see [api/determinism.md](../api/determinism.md) for the API): + - `detail::IsDeterminismSafe` — false by default (primary + template); true for integers, enums, `fpx16_16`, `float` (the + `fp32_pinned` backend's registered `Scalar`), and the four + `SimMath::Vec2/Vec3` types (one pair per backend). `double` is + intentionally not safe: no backend uses it. + - `LAIGE_DETERMINISM_SAFE(Type, MemberTypes...)` — the declaration + that a user struct's storage is exactly the listed member types. + The member list is verified at the mark site (a `static_assert` in + the specialization: a `double` in the list is a compile error there, + before any system can use the component), and the mark specializes + the trait. An unmarked user struct is never safe, even if its + members look safe: the mark is the declaration, not a heuristic. + - `World::registerSystem` folds the trait over the system's + declared I/O (`detail::IoComponentSafety>`): a + non-safe component fails the `static_assert` with an actionable + message naming the fix (mark the component, or change the storage). + - The compile-check fixtures (`tests/laige-sim/compile_fail/`, CTest + `trait_compile_*`) prove both halves: a marked safe component + compiles; an unmarked struct, a `double` member, and a `double` in + the mark's member list each fail to compile with the G-R8 message. +2. **Source scan (translation units).** `tools/laige-determinism-lint` + scans every `src/laige-sim/**` translation unit for raw `float` / + `double` type tokens, float/double literals, and `unordered_*` + containers (PRD §10.3 also bans unordered containers in sim hot + paths — a deterministic container, if ever needed, gets an ADR + first). The trait cannot see helper code outside component storage; + the lint cannot see template instantiations; together they cover + the sim module. Runs in CI (`determinism-lint` job, both workflows) + and in ctest (`determinism-lint-*` fixture tests + real tree). + +## The source scan and its exception policy + +The lint is textual: it strips comments, string/char literals, and raw +strings before matching, so a `float` in a comment or a string is not a +violation (documentation is not code). It matches case-sensitive, +word-bounded tokens, so `Float`, `fromFloat`, `next_float01`, `toFloat` +are not violations (they are not the type token). + +**Exceptions** are explicit, same-line markers: + +```cpp +double budgetMs = 1.0; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic only (ARCH-009: never enters sim state) +``` + +Rules (the documented false-positive / legitimate-off-path policy): + +- The marker must be a trailing `//` comment **on the offending line**, + must name the rule id `G-R8`, and must carry a non-empty reason. A + line containing `LAIGE-DETERM-EXCEPTION` that does not match the + format is itself a violation (a marker cannot be half-written). +- Every suppressed line is counted and printed in the lint output + (EXC-006: exceptions stay visible — they appear in every CI run and + ctest, so new markers need human review by construction). +- A marker is only legitimate for uses that provably never touch + deterministic state: wall-clock diagnostics (the M1-SYS-03 + system-timing doubles — ARCH-009), the presentation alpha conversion + (a wall-clock fact by design), the JSON number policy (ADR 0003: + config parsing stores numbers as doubles and must round-trip them + exactly), and the trait's own registration of `float` as the + `fp32_pinned` backend's `Scalar`. + +## PRNG substreams + +Randomness in deterministic mode is a *named input*, not an accident: + +- The engine seed (`EngineConfig.seed`, default + `laige::kDefaultSimulationSeed == 0`) is the master seed; it is part + of the config and of replay identity (ADR 0002) and is logged on + `engine/run_started`. +- **Each registered system gets its own substream**, derived as + `Prng::deriveSubstream(seed, systemId)` (`systemId` is the dense + registration id, starting at 1; id 0 is the master and is never + assigned to a system). The derivation is the Prng's own contract + ([api/prng.md](../api/prng.md)): `deriveSubstream(seed, id) == + Prng(seed + id * kSplitmix64Increment)` — a fresh, independent stream + that never interleaves with another's. +- The system's stream lives in `SystemRecord` and is **advanced in + place** during `runSystems` — its current state *is* the replay state. + `SystemContext.rng` points at it (or is `nullptr` in a world created + with `deterministic == false`). +- A system that draws is deterministic *because the draw is at a fixed + position in the system's run* (e.g. before its iteration) and comes + from a fixed seed — the call order is the replay state, not the + machine. +- `DeterminismMode.SubstreamsMatchPrngDerivation` cross-checks the + wiring against an independently constructed `Prng::deriveSubstream`; + `DeterminismMode.SubstreamsAreIndependent` verifies two systems draw + from different streams. +- No system *needs* randomness: a system that never draws `ctx.rng` + costs nothing (one null check per tick). + +## Deterministic mode vs. disabled + +`EngineConfig.determinism.enabled` (default `true`) selects the mode: + +| | enabled (default) | disabled | +|---|---|---| +| Math | SimMath ops only (the active backend) | SimMath still recommended; the engine does not police a game that opts out | +| PRNG substreams | created per system (`SystemContext.rng` non-null) | none (`SystemContext.rng == nullptr`; `DeterminismMode.DeterminismDisabledHasNoSubstream`) | +| G-R8 trait | enforced in `World::registerSystem` | enforced (the trait is a property of the component storage, not the mode — a sim component that stored `double` was never legal) | +| Replay identity | seed + backend + config + inputs | not claimed | + +Disabling determinism is an escape hatch for non-deterministic +prototypes, not a different math policy: the same SimMath ops, the same +storage rules — only the PRNG and the replay promise are switched off. + +## The config surface (provisional) + +`EngineConfig` (laige/sim/engine.h) carries the two keys: + +```cpp +struct EngineConfig { + std::uint32_t tickRateHz{...}; + std::uint32_t entityCapacity{...}; + std::uint32_t churnPerFrameBudget{...}; + std::uint64_t seed{laige::kDefaultSimulationSeed}; // 0..2^64-1 (programmatic) + DeterminismConfig determinism{}; // {enabled, math} +}; +``` + +- `DeterminismConfig { bool enabled{true}; SimMathBackend math{FixedPoint16_16}; }` + with `SimMathBackend::FixedPoint16_16` (id `fixed_point_16_16`, the + default) and `SimMathBackend::FloatPinned32` (id `float_pinned_32`). +- The JSON surface is **provisional** (M1-HEAD-01): `parseEngineConfig` + accepts `{"seed": 0..2^53, "determinism": {"enabled": bool, + "math": "fixed_point_16_16"|"float_pinned_32"}}`. The seed is bounded + to `2^53` in JSON because ADR 0003 stores numbers as doubles (exact to + 2^53); the programmatic `EngineConfig.seed` is the full `uint64_t`. + Unknown nested keys warn (`config/unknown_key`) and are ignored — the + forward-compat rule. **M1-CFG-01 owns the final config schema**; these + keys land on the provisional surface until then. +- The engine selects the backend once at init (compile-time dispatch, + ADR 0002): it registers the matching built-in component + (`Position2DFpx16` or `Position2DFp32`) first and builds the + presentation snapshot for the same backend. The selection is logged on + `engine/run_started` (`seed`, `determinism`, `math` fields). + +## Verification (this step) + +- `ctest -R determinism_mode` — the `DeterminismMode.*` + (same-seed identical 256-tick hash streams; different-seed divergence; + substream golden cross-check + independence; disabled-mode null rng), + `DeterminismEngine.*` (backend selection: built-in component + + snapshot), and `DeterminismConfigParse.*` (the seed/determinism keys: + defaults, valid values, the rejection table) suites. +- `ctest -R trait_compile` — the G-R8 trait compile-checks (one positive + fixture, three negative fixtures, each asserting the actionable G-R8 + message). +- `ctest -R determinism-lint` — the source-scan fixture tests + real + tree (plus the `determinism-lint` CI job in both workflows). +- Cross-target: M1-DET-04 (the detcheck matrix over both backends). + +## Related + +- [ADR 0002 — Deterministic math strategy](../decisions/0002-deterministic-math.md) + (the two backends, one op surface, replay identity). +- [ADR 0003 — Config JSON](../decisions/0003-config-json.md) (the + double-number policy the JSON seed bound comes from). +- [api/determinism.md](../api/determinism.md) — the trait API + (`IsDeterminismSafe`, `LAIGE_DETERMINISM_SAFE`). +- [api/prng.md](../api/prng.md) — `laige::Prng`, substream derivation. +- [api/sim_math.md](../api/sim_math.md) — the SimMath op surface and + backend policies. +- [api/engine.md](../api/engine.md) — the `seed`/`determinism` config + keys and the backend selection. +- [api/detcheck.md](../api/detcheck.md) — the replay-comparison tool + (M1-DET-04 runs it against the two backends). diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index 61cc478..9be4a34 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -39,6 +39,7 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) | Determinism check | `./build/bin/laige-detcheck --scenario=` | | API manifest | `cmake --build build --target laige-api` | | Include-graph lint + dependency count | `python3 tools/laige-include-lint` | +| Determinism source scan (sim module) | `python3 tools/laige-determinism-lint` | Notes: @@ -65,6 +66,19 @@ Notes: `include-lint` in `ci-pull.yml`/`ci.yml`), and the CTest suite runs it against the real tree in every build job (`tests/tools`). On Windows use `python tools\laige-include-lint`. +- Determinism source scan (M1-DET-01): platform-independent (Python 3 + stdlib only, no setup). It scans `src/laige-sim/**` for raw + `float`/`double` (type tokens and float/double literals) and + `unordered_*` containers — the textual half of the G-R8 guarantee + (the other half is the compile-time trait in `World::registerSystem`, + checked by the `trait_compile_*` CTest fixtures). Same-line + `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers are the documented + false-positive policy (every suppressed line is counted and printed). + CI runs it on every PR and merge (job `determinism-lint` in + `ci-pull.yml`/`ci.yml`), and the CTest suite runs it against fixture + trees and the real tree in every build job (`tests/tools`, + `determinism-lint-*`). Scope, rules, and the exception policy: + [docs/concepts/determinism.md](../concepts/determinism.md). - Test (TSan tree): registered tests automatically run with `TSAN_OPTIONS=halt_on_error=1` (wired in `tests//CMakeLists.txt` when `LAIGE_TSAN=ON`), so a data race makes `ctest` fail with a non-zero diff --git a/docs/testing.md b/docs/testing.md index 6c6946d..909c63a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -131,7 +131,45 @@ target may return any `Status`; the run fails only on process death stream that produced the failure (the committed KATs pin the default-seed values, so the override never hides a KAT regression). -## 5. Running this step's checks +## 5. Determinism test entries (M1-DET-01) + +The M1-DET-01 step adds three kinds of determinism checks, all part of +the standard ctest suite in every P0 job (and both sanitizer trees): + +- **`ctest -R determinism_mode`** — the runtime determinism suites + (`DeterminismMode.*`, `DeterminismEngine.*`, `DeterminismConfigParse.*` + in `tests/laige-sim/determinism_tests.cpp`, part of the + `laige-sim_tests` executable): a trivial moving-entity sim produces + bit-identical per-tick FNV-1a state hashes in two consecutive runs + (same build, same seed) over 256 ticks; a different seed diverges; the + per-system PRNG substreams match `Prng::deriveSubstream` exactly and + are independent; `deterministic == false` yields `SystemContext.rng == + nullptr`; the engine selects the configured SimMath backend (built-in + component + presentation snapshot); and the provisional `seed` / + `determinism` config keys (defaults, valid values, the rejection + table). The machine-greppable `determinism-tick-stream` line lands in + the ctest output. +- **`ctest -R trait_compile`** — the G-R8 trait compile-checks + (`tests/laige-sim/compile_fail/`, generated `cmake -P` check scripts): + one positive fixture (a marked determinism-safe component compiles) + and three negative fixtures (a `double` member, an unmarked user + struct, and a `double` in the mark's member list each fail to compile + with the actionable G-R8 message). Each check asserts the exit code + **and** a required stderr fragment, so an incidental compiler error + cannot masquerade as the trait firing. +- **`ctest -R determinism-lint`** — the sim-source determinism scan + (`tools/laige-determinism-lint`, `tests/tools`): fixture trees + (clean tree with one marked exception → exit 0; one violation per rule + D1a/D1b/D1c/D2/D3 → exit 1) and the real repository tree (→ exit 0). + The same lint runs as the `determinism-lint` CI job in both + `ci-pull.yml` and `ci.yml`. + +These entries, together with the `prng` suite (M0-CORE-06) and the +`detcheck` matrix (M1-DET-04), implement TEST-004 (determinism tests +compare state hashes or replay outcomes wherever determinism is +promised) at the scope ARCH-010 requires. + +## 6. Running this step's checks | Purpose | Command | |---|---| diff --git a/laige-api.json b/laige-api.json index 2af3a99..9f8c056 100644 --- a/laige-api.json +++ b/laige-api.json @@ -14,6 +14,7 @@ "src/laige-core/include/laige/sim_math.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", + "src/laige-sim/include/laige/sim/determinism.h", "src/laige-sim/include/laige/sim/engine.h", "src/laige-sim/include/laige/sim/entity.h", "src/laige-sim/include/laige/sim/game_loop.h", @@ -426,84 +427,96 @@ {"name": "laige::ComponentInfo::alignment", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 144, "signature": "std::uint32_t alignment{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kMaxComponentTypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 149, "signature": "inline constexpr std::uint32_t kMaxComponentTypes = 256", "summary": "The engine-level cap on component types per world (CORE-005). See the preamble for the rationale and the ADR path to raise it.", "budget": null, "experimental": false}, {"name": "LAIGE_COMPONENT", "kind": "macro", "header": "src/laige-sim/include/laige/sim/component.h", "line": 196, "signature": "#define LAIGE_COMPONENT(Type)", "summary": "Mark T as a Laige component (FR-1.2; S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 228, "signature": "struct EngineConfig", "summary": "The typed headless-engine configuration (M1-HEAD-01; the provisional config surface — see the header preamble \"The config surface\"). A plain value: the engine copies it into the EngineConfig echo read back through config().", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 231, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20-120 validated at Engine::create; default kDefaultTickRateHz).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 236, "signature": "std::uint32_t entityCapacity{0}", "summary": "The declared scene budget (G-R3): the World's entity capacity. 0 = an empty scene (a valid world that creates no entities — entity creation on it fails with BudgetExhausted; the game declares its budget, the engine does not guess one).", "budget": null, "experimental": false}, - {"name": "laige::EngineConfig::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 239, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget (0 disables the guardrail; default kDefaultChurnPerFrameBudget).", "budget": null, "experimental": false}, - {"name": "laige::parseEngineConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 266, "signature": "[[nodiscard]] Result parseEngineConfig(const JsonValue& doc) noexcept", "summary": "Load the headless-engine configuration from a parsed JSON document (the provisional M1-HEAD-01 config surface; M1-CFG-01 owns the full declarative schema — see the header preamble for the keys, the defaults, and the rejection table). The document must be a top-level object; every accepted key is optional (defaults above).", "budget": "O(document keys); cold path, warn fields allocate only when a key is rejected.", "experimental": false}, - {"name": "laige::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 273, "signature": "class Engine", "summary": "The headless engine (M1-HEAD-01): config -> world -> systems -> loop, then the ordered CONC-006 shutdown. See the header preamble for the lifecycle, the run contract, the shutdown order, the config surface, the determinism scope, and the misuse warnings.", "budget": null, "experimental": false}, - {"name": "laige::Engine::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 289, "signature": "[[nodiscard]] static Result create(const EngineConfig& config) noexcept", "summary": "Setup phase (the engine's only backing allocations happen in the World's create — the registry tables and, when capacity > 0, the per-slot tables): validate the typed config, create the World (entityCapacity, churnPerFrameBudget), and register the built-in Position2DFpx16 (the engine's built-ins always come first — ARCH-010). O(1) beyond the World's setup allocations.", "budget": null, "experimental": false}, - {"name": "laige::Engine::world", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 295, "signature": "[[nodiscard]] World* world() noexcept", "summary": "The engine's world (the game setup phase: register components and systems here, BEFORE run_headless). nullptr after shutdown or on a moved-from engine (CPP-008 nullability; the stopped-state precedent). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::config", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 299, "signature": "[[nodiscard]] const EngineConfig& config() const noexcept", "summary": "The engine configuration echo (the validated values). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::run_headless", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 325, "signature": "[[nodiscard]] Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept", "summary": "Run the headless engine: compute the schedule, create the loop (with the presentation onTick hook) and the snapshot, drive frames until maxTicks ticks have completed (0 = the server form: run until the process ends), then shut down (always — even on a failed frame; CONC-006). One engine run per engine: a second call (after any outcome) fails with InvalidArgument without logging (the stopped-state precedent).", "budget": "O(maxTicks x per-tick system work), bounded per frame by frameBudgetTicks (PERF-002); setup allocates three one-shot objects (the GameLoop, the PresentationSnapshot, and the snapshot slot table); the frame path allocates nothing.", "experimental": false}, - {"name": "laige::Engine::shutdown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 333, "signature": "void shutdown() noexcept", "summary": "The ordered, IDEMPOTENT shutdown (the header preamble \"The ordered shutdown\": loop -> world clear -> storage release -> logging flush). Safe before a run, after a run, and after a failed run; the destructor calls it. O(world clear cost); no logging on the success path beyond the facade's own flush.", "budget": null, "experimental": false}, - {"name": "laige::Engine::isShutDown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 337, "signature": "[[nodiscard]] bool isShutDown() const noexcept", "summary": "True once shutdown() has completed (or on a moved-from engine). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::Engine::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 343, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, - {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 348, "signature": "Engine(Engine&& other) noexcept", "summary": "Move transfers the owned state; the source becomes a STOPPED engine (world() nullptr, run_headless fails, shutdown is a no-op — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, - {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 349, "signature": "Engine& operator=(Engine&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 350, "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": 351, "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": 355, "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": 187, "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": 188, "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": 189, "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": 191, "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": 192, "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": 200, "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": 203, "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": 217, "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": 218, "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": 219, "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": 220, "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": 221, "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": 222, "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": 223, "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": 234, "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": 251, "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": 252, "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": 253, "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": 254, "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": 255, "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": 256, "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": 257, "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": 258, "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": 330, "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": 334, "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": 339, "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": 345, "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::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 351, "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": 356, "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": 366, "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": 373, "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": 376, "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": 379, "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": 383, "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": 387, "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": 402, "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": 408, "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": 429, "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": 434, "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": 440, "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": 451, "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": 461, "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": 478, "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": 486, "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": 492, "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": 497, "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": 532, "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": 573, "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": 578, "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": 585, "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": 607, "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": 626, "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": 639, "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": 648, "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::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 661, "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": 665, "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": 666, "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": 667, "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": 668, "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": 673, "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::SimMathBackend", "kind": "enum", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 165, "signature": "enum class SimMathBackend : std::uint8_t", "summary": "The SimMath backend a deterministic run uses (ADR 0002; the typed form of the config ids \"fixed_point_16_16\" / \"float_pinned_32\"). Selected once at engine init (Engine::create consumes it — the ADR's \"factory-selected at init\", no per-call dispatch).", "budget": null, "experimental": false}, + {"name": "laige::SimMathBackend::FixedPoint16_16", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 171, "signature": "FixedPoint16_16", "summary": "The default backend: Q16.16 in int32_t storage, int64_t intermediates (laige/fpx16_16.h). Bit-exact across all builds, platforms, ISAs, and compilers (guaranteed by the C++20 language standard — ADR 0002); required for lockstep (AC-10.3) and authoritative MMO.", "budget": null, "experimental": false}, + {"name": "laige::SimMathBackend::FloatPinned32", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 177, "signature": "FloatPinned32", "summary": "Opt-in IEEE float semantics: binary32 (`float`) with the pinned flag set (ADR 0002, laige/sim_math.h). Bit-exact across runs of the same build on the same platform/ISA; cross-ISA identity is the detcheck matrix's job (M1-DET-04) — a failing pair is declared unsupported for this backend.", "budget": null, "experimental": false}, + {"name": "laige::DeterminismConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 183, "signature": "struct DeterminismConfig", "summary": "The typed determinism block of EngineConfig (M1-DET-01; ADR 0002). A plain value: the engine copies it (config echo, engine.h) — no ownership, no state, trivially copyable.", "budget": null, "experimental": false}, + {"name": "laige::DeterminismConfig::enabled", "kind": "variable", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 186, "signature": "bool enabled{true}", "summary": "Deterministic mode on/off (S-7: deterministic by default). M1 semantics in the header preamble \"Determinism mode semantics\".", "budget": null, "experimental": false}, + {"name": "laige::DeterminismConfig::math", "kind": "variable", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 188, "signature": "SimMathBackend math{SimMathBackend::FixedPoint16_16}", "summary": "The SimMath backend the deterministic run uses (ADR 0002).", "budget": null, "experimental": false}, + {"name": "LAIGE_DETERMINISM_SAFE", "kind": "macro", "header": "src/laige-sim/include/laige/sim/determinism.h", "line": 299, "signature": "#define LAIGE_DETERMINISM_SAFE(Type, ...)", "summary": "Mark Type as a determinism-safe storage/component type (G-R8, S-7): declare that Type's members are exactly the listed member types (every member; order is irrelevant — the list is a set of types). The mark specializes the trait with the verified member list:", "budget": null, "experimental": false}, + {"name": "laige::kDefaultSimulationSeed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 260, "signature": "inline constexpr std::uint64_t kDefaultSimulationSeed = 0", "summary": "The default master simulation seed (M1-DET-01; CORE-005). 0 is a valid master seed: the Prng's state is nonzero for every 64-bit seed (the xorshift128+ state transform — laige/prng.h), so no special invalid seed is needed.", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig", "kind": "struct", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 266, "signature": "struct EngineConfig", "summary": "The typed headless-engine configuration (M1-HEAD-01; the provisional config surface — see the header preamble \"The config surface\"). A plain value: the engine copies it into the EngineConfig echo read back through config().", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 269, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": "The simulation tick rate in HERTZ (FR-1.1: 20-120 validated at Engine::create; default kDefaultTickRateHz).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::entityCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 274, "signature": "std::uint32_t entityCapacity{0}", "summary": "The declared scene budget (G-R3): the World's entity capacity. 0 = an empty scene (a valid world that creates no entities — entity creation on it fails with BudgetExhausted; the game declares its budget, the engine does not guess one).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 277, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget (0 disables the guardrail; default kDefaultChurnPerFrameBudget).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::seed", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 287, "signature": "std::uint64_t seed{kDefaultSimulationSeed}", "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 kDefaultSimulationSeed (0 — a valid master seed: the Prng's state is nonzero for every 64-bit seed, prng.h). The programmatic path accepts the full 64 bits; the JSON config path is bounded to exact integers in 0..2^53 (the ADR 0003 number policy — parseEngineConfig's documented limit).", "budget": null, "experimental": false}, + {"name": "laige::EngineConfig::determinism", "kind": "variable", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 293, "signature": "DeterminismConfig determinism{}", "summary": "The determinism block (M1-DET-01; ADR 0002): the mode flag and the selected SimMath backend. See the header preamble \"The config surface\" for the JSON keys and \"Built-in components and the determinism scope\" for the semantics; the full promised scope is docs/concepts/determinism.md.", "budget": null, "experimental": false}, + {"name": "laige::parseEngineConfig", "kind": "function", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 330, "signature": "[[nodiscard]] Result parseEngineConfig(const JsonValue& doc) noexcept", "summary": "Load the headless-engine configuration from a parsed JSON document (the provisional M1-HEAD-01 config surface; M1-CFG-01 owns the full declarative schema — see the header preamble for the keys, the defaults, and the rejection table). The document must be a top-level object; every accepted key is optional (defaults above).", "budget": "O(document keys); cold path, warn fields allocate only when a key is rejected.", "experimental": false}, + {"name": "laige::Engine", "kind": "class", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 438, "signature": "class Engine", "summary": "The headless engine (M1-HEAD-01): config -> world -> systems -> loop, then the ordered CONC-006 shutdown. See the header preamble for the lifecycle, the run contract, the shutdown order, the config surface, the determinism scope, and the misuse warnings.", "budget": null, "experimental": false}, + {"name": "laige::Engine::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 456, "signature": "[[nodiscard]] static Result create(const EngineConfig& config) noexcept", "summary": "Setup phase (the engine's only backing allocations happen in the World's create — the registry tables and, when capacity > 0, the per-slot tables): validate the typed config, create the World (entityCapacity, churnPerFrameBudget, seed, determinism mode), and register the built-in component matching the configured SimMath backend (Position2DFpx16 default, Position2DFp32 for float_pinned_32 — M1-DET-01; the engine's built-ins always come first — ARCH-010). O(1) beyond the World's setup allocations.", "budget": null, "experimental": false}, + {"name": "laige::Engine::world", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 462, "signature": "[[nodiscard]] World* world() noexcept", "summary": "The engine's world (the game setup phase: register components and systems here, BEFORE run_headless). nullptr after shutdown or on a moved-from engine (CPP-008 nullability; the stopped-state precedent). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::config", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 466, "signature": "[[nodiscard]] const EngineConfig& config() const noexcept", "summary": "The engine configuration echo (the validated values). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::run_headless", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 492, "signature": "[[nodiscard]] Status run_headless(std::uint64_t maxTicks, std::uint32_t frameBudgetTicks = kDefaultMaxCatchUpTicks) noexcept", "summary": "Run the headless engine: compute the schedule, create the loop (with the presentation onTick hook) and the snapshot, drive frames until maxTicks ticks have completed (0 = the server form: run until the process ends), then shut down (always — even on a failed frame; CONC-006). One engine run per engine: a second call (after any outcome) fails with InvalidArgument without logging (the stopped-state precedent).", "budget": "O(maxTicks x per-tick system work), bounded per frame by frameBudgetTicks (PERF-002); setup allocates three one-shot objects (the GameLoop, the PresentationSnapshot, and the snapshot slot table); the frame path allocates nothing.", "experimental": false}, + {"name": "laige::Engine::shutdown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 500, "signature": "void shutdown() noexcept", "summary": "The ordered, IDEMPOTENT shutdown (the header preamble \"The ordered shutdown\": loop -> world clear -> storage release -> logging flush). Safe before a run, after a run, and after a failed run; the destructor calls it. O(world clear cost); no logging on the success path beyond the facade's own flush.", "budget": null, "experimental": false}, + {"name": "laige::Engine::isShutDown", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 504, "signature": "[[nodiscard]] bool isShutDown() const noexcept", "summary": "True once shutdown() has completed (or on a moved-from engine). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::Engine::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 510, "signature": "[[nodiscard]] GameLoopStats stats() const noexcept", "summary": "The last run's loop accounting (frames, ticks, droppedTicks, droppedFrames — the GameLoopStats since the run's loop construction; all zeros before the first run). O(1), no allocation, no side effects (the profiler feed, M1-PROF-01).", "budget": null, "experimental": false}, + {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 515, "signature": "Engine(Engine&& other) noexcept", "summary": "Move transfers the owned state; the source becomes a STOPPED engine (world() nullptr, run_headless fails, shutdown is a no-op — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::Engine::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 516, "signature": "Engine& operator=(Engine&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Engine::Engine", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/engine.h", "line": 517, "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": 518, "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": 522, "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": 188, "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": 189, "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": 190, "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": 192, "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": 193, "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": 201, "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": 204, "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": 218, "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": 219, "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": 220, "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": 221, "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": 222, "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": 223, "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": 224, "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": 235, "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": 252, "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": 253, "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": 254, "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": 255, "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": 256, "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": 257, "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": 258, "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": 259, "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": 331, "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": 335, "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": 340, "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": 346, "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": 352, "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": 360, "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": 366, "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": 371, "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": 381, "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": 388, "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": 391, "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": 394, "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": 398, "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": 402, "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": 417, "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": 423, "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": 444, "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": 449, "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": 455, "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": 466, "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": 476, "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": 493, "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": 501, "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": 507, "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": 512, "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": 547, "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": 588, "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": 593, "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": 600, "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": 622, "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": 641, "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": 654, "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": 663, "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::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 676, "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": 680, "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": 681, "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": 682, "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": 683, "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": 688, "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}, @@ -533,27 +546,29 @@ {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 383, "signature": "GameLoop& operator=(GameLoop&& other) noexcept", "summary": null, "budget": null, "experimental": false}, {"name": "laige::GameLoop::GameLoop", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 384, "signature": "GameLoop(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::GameLoop::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/game_loop.h", "line": 385, "signature": "GameLoop& operator=(const GameLoop&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Position2D", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 257, "signature": "template struct Position2D", "summary": "The entity's 2D simulation-space position (the ground plane — PRD §4; the axes/units contract lands with the concepts docs). The value is the selected SimMath backend's Vec2 (ADR 0002: one template instantiation per backend, factory-selected at engine init). A data carrier (S-8): trivially copyable, no behavior — the LAIGE_COMPONENT marks below register both instantiations in the same path as user components (M1-ECS-02).", "budget": null, "experimental": false}, - {"name": "laige::Position2D::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 259, "signature": "sim::SimMath::Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 262, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 263, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Position2DFpx16", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 267, "signature": "using Position2DFpx16 = Position2D", "summary": "The two backend instantiations: a game registers the one matching its init-time backend selection (ADR 0002, `determinism.math`).", "budget": null, "experimental": false}, - {"name": "laige::Position2DFp32", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 268, "signature": "using Position2DFp32 = Position2D", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot", "kind": "class", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 274, "signature": "template class PresentationSnapshot", "summary": "PresentationSnapshot — the per-tick presentation state (M1-LOOP-02)", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::Vec2", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 277, "signature": "using Vec2 = sim::SimMath::Vec2", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::Scalar", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 278, "signature": "using Scalar = sim::SimMath::Scalar", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 284, "signature": "struct Options", "summary": "The typed configuration (API-006): the tick rate, validated to the loop's documented 20–120 Hz range at create() — it must EQUAL the driven GameLoop's rate (the preamble \"Misuse warnings\").", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 285, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 296, "signature": "[[nodiscard]] static Result create(World& world, std::int64_t startReferenceNs, Options options) noexcept", "summary": "Setup path (the only backing allocation: the per-slot record table, sized by world.capacity()). The world outlives the snapshot. startReferenceNs is the driven GameLoop's start reference (0 before the loop's first frame; the preamble \"alpha contract\"). Rejection: tickRateHz outside 20–120 → ErrorCode::InvalidArgument + one rate-limited warn (presentation/tick_rate_invalid) — FR-12.3/CORE-008, never silent.", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::onTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 309, "signature": "void onTick(std::uint64_t tick) noexcept", "summary": "One COMPLETED tick (the GameLoop's onTick hook fires this after every completed tick; tests may drive it manually): rolls prev ← curr and refreshes curr from the world's current Position2D values (the preamble \"per-tick snapshot production\"). Cost: O(bounded archetype scan + matching live entities), no allocation, no logging (LOG-003). A rejected refresh (a nested iteration — a caller misuse) leaves the previous tick's prev/curr in place (the guard's event carries the failure); lastTick still records the tick.", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::onRenderFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 315, "signature": "void onRenderFrame(std::int64_t renderNs) noexcept", "summary": "One presentation frame: recomputes the stored alpha from renderNs (the preamble \"alpha contract\"): exact integer anchor arithmetic, clamped to [0, 1] (never extrapolates). A few integer ops; no allocation, no logging.", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::alpha", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 320, "signature": "[[nodiscard]] Scalar alpha() const noexcept", "summary": "The frame's interpolation alpha (the backend scalar in [0, 1]; 0 before the first completed tick). The M1-PROF-01 / debug overlay feed.", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::lastTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 324, "signature": "[[nodiscard]] std::uint64_t lastTick() const noexcept", "summary": "The number of completed ticks the snapshot has seen (0 before the first onTick). The M1-PROF-01 feed; the engine's sync check.", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::sample_position", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 332, "signature": "[[nodiscard]] Result sample_position(Entity e) const noexcept", "summary": "The interpolated 2D position of `e` (the preamble \"Sample semantics\"): lerp(prev, curr, alpha) for a synced entity, the current value for one added between ticks (snaps), Invalid- Argument for stale handles (warn-once) and live handles without a Position2D. Cost: O(1) (handle check + component lookup + one 2D lerp); no allocation, no logging (LOG-003).", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 337, "signature": "PresentationSnapshot(PresentationSnapshot&& other) noexcept", "summary": "Move: O(1) pointer swap; the moved-from snapshot is stopped (every operation fails with InvalidArgument; no world access, no logging — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 338, "signature": "PresentationSnapshot& operator=(PresentationSnapshot&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 341, "signature": "PresentationSnapshot(const PresentationSnapshot&) = delete", "summary": "No copies (the unique backing table).", "budget": null, "experimental": false}, - {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 342, "signature": "PresentationSnapshot& operator=(const PresentationSnapshot&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Position2D", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 258, "signature": "template struct Position2D", "summary": "The entity's 2D simulation-space position (the ground plane — PRD §4; the axes/units contract lands with the concepts docs). The value is the selected SimMath backend's Vec2 (ADR 0002: one template instantiation per backend, factory-selected at engine init). A data carrier (S-8): trivially copyable, no behavior — the LAIGE_COMPONENT marks below register both instantiations in the same path as user components (M1-ECS-02).", "budget": null, "experimental": false}, + {"name": "laige::Position2D::pos", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 260, "signature": "sim::SimMath::Vec2 pos{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 263, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::LAIGE_COMPONENT", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 264, "signature": "LAIGE_COMPONENT(Position2D)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::LAIGE_DETERMINISM_SAFE", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 272, "signature": "LAIGE_DETERMINISM_SAFE( Position2D, sim::SimMath::Vec2)", "summary": "M1-DET-01 (G-R8): the built-in is determinism-safe STORAGE — one member, the SimMath-registered Vec2 of its backend (determinism.h trait). The marks let the built-in appear in a system's declared I/O (registerSystem's G-R8 check); without them, every game system touching the engine's own position component would fail to compile.", "budget": null, "experimental": false}, + {"name": "laige::LAIGE_DETERMINISM_SAFE", "kind": "function", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 274, "signature": "LAIGE_DETERMINISM_SAFE( Position2D, sim::SimMath::Vec2)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Position2DFpx16", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 279, "signature": "using Position2DFpx16 = Position2D", "summary": "The two backend instantiations: a game registers the one matching its init-time backend selection (ADR 0002, `determinism.math`).", "budget": null, "experimental": false}, + {"name": "laige::Position2DFp32", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 280, "signature": "using Position2DFp32 = Position2D", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot", "kind": "class", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 286, "signature": "template class PresentationSnapshot", "summary": "PresentationSnapshot — the per-tick presentation state (M1-LOOP-02)", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Vec2", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 289, "signature": "using Vec2 = sim::SimMath::Vec2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Scalar", "kind": "alias", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 290, "signature": "using Scalar = sim::SimMath::Scalar", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 296, "signature": "struct Options", "summary": "The typed configuration (API-006): the tick rate, validated to the loop's documented 20–120 Hz range at create() — it must EQUAL the driven GameLoop's rate (the preamble \"Misuse warnings\").", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::Options::tickRateHz", "kind": "variable", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 297, "signature": "std::uint32_t tickRateHz{kDefaultTickRateHz}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 308, "signature": "[[nodiscard]] static Result create(World& world, std::int64_t startReferenceNs, Options options) noexcept", "summary": "Setup path (the only backing allocation: the per-slot record table, sized by world.capacity()). The world outlives the snapshot. startReferenceNs is the driven GameLoop's start reference (0 before the loop's first frame; the preamble \"alpha contract\"). Rejection: tickRateHz outside 20–120 → ErrorCode::InvalidArgument + one rate-limited warn (presentation/tick_rate_invalid) — FR-12.3/CORE-008, never silent.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::onTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 321, "signature": "void onTick(std::uint64_t tick) noexcept", "summary": "One COMPLETED tick (the GameLoop's onTick hook fires this after every completed tick; tests may drive it manually): rolls prev ← curr and refreshes curr from the world's current Position2D values (the preamble \"per-tick snapshot production\"). Cost: O(bounded archetype scan + matching live entities), no allocation, no logging (LOG-003). A rejected refresh (a nested iteration — a caller misuse) leaves the previous tick's prev/curr in place (the guard's event carries the failure); lastTick still records the tick.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::onRenderFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 327, "signature": "void onRenderFrame(std::int64_t renderNs) noexcept", "summary": "One presentation frame: recomputes the stored alpha from renderNs (the preamble \"alpha contract\"): exact integer anchor arithmetic, clamped to [0, 1] (never extrapolates). A few integer ops; no allocation, no logging.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::alpha", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 332, "signature": "[[nodiscard]] Scalar alpha() const noexcept", "summary": "The frame's interpolation alpha (the backend scalar in [0, 1]; 0 before the first completed tick). The M1-PROF-01 / debug overlay feed.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::lastTick", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 336, "signature": "[[nodiscard]] std::uint64_t lastTick() const noexcept", "summary": "The number of completed ticks the snapshot has seen (0 before the first onTick). The M1-PROF-01 feed; the engine's sync check.", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::sample_position", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 344, "signature": "[[nodiscard]] Result sample_position(Entity e) const noexcept", "summary": "The interpolated 2D position of `e` (the preamble \"Sample semantics\"): lerp(prev, curr, alpha) for a synced entity, the current value for one added between ticks (snaps), Invalid- Argument for stale handles (warn-once) and live handles without a Position2D. Cost: O(1) (handle check + component lookup + one 2D lerp); no allocation, no logging (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 349, "signature": "PresentationSnapshot(PresentationSnapshot&& other) noexcept", "summary": "Move: O(1) pointer swap; the moved-from snapshot is stopped (every operation fails with InvalidArgument; no world access, no logging — the GameLoop moved-out precedent).", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 350, "signature": "PresentationSnapshot& operator=(PresentationSnapshot&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::PresentationSnapshot", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 353, "signature": "PresentationSnapshot(const PresentationSnapshot&) = delete", "summary": "No copies (the unique backing table).", "budget": null, "experimental": false}, + {"name": "laige::PresentationSnapshot::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/presentation.h", "line": 354, "signature": "PresentationSnapshot& operator=(const PresentationSnapshot&) = delete", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access", "kind": "enum", "header": "src/laige-sim/include/laige/sim/query.h", "line": 234, "signature": "enum class Access : std::uint8_t", "summary": "The declared per-component access of a query (FR-1.3). Read: the component is only read during the iteration; Write: the system mutates it (through the query's reference or an in-place addComponent overwrite — both legal, see the preamble \"Iteration legality\"). M1-SYS-01's system I/O declarations reuse this value type.", "budget": null, "experimental": false}, {"name": "laige::Access::Read", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 235, "signature": "Read = 0", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access::Write", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 236, "signature": "Write = 1", "summary": null, "budget": null, "experimental": false}, @@ -561,39 +576,40 @@ {"name": "laige::Read::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 247, "signature": "static constexpr Access value = Access::Read", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 249, "signature": "struct Write", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 250, "signature": "static constexpr Access value = Access::Write", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 377, "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": 378, "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": 383, "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}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 385, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 388, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 396, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, - {"name": "laige::kMaxSystemDependencies", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 403, "signature": "inline constexpr std::uint32_t kMaxSystemDependencies = 16", "summary": "The bound on one system's direct depends_on list (CORE-005). A direct dependency list is a small hand-written declaration; beyond 16 the ordering should be carried by registration position (a barrier is registration order, not a dependency list). Raising it is an ADR.", "budget": null, "experimental": false}, - {"name": "laige::kSystemTimingWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 410, "signature": "inline constexpr std::uint32_t kSystemTimingWindowSamples = 64", "summary": "The per-system rolling window capacity (M1-SYS-03; CORE-005): the number of measured run times (ms) kept per system in the rolling histogram — ~1.1 s of samples at the default 60 Hz tick rate. The window is fixed at world construction (a setup-path allocation); raising the capacity is an ADR, not a knob.", "budget": null, "experimental": false}, - {"name": "laige::kBudgetCriticalMultiplier", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 416, "signature": "inline constexpr std::uint32_t kBudgetCriticalMultiplier = 3", "summary": "The over-budget multiplier that escalates the budget_overrun warn into a budget_critical error event (M1-SYS-03; PRD §9.3 G-R5: \"over 3× → error event\"; CORE-005). The warn fires strictly above 1× the declared budget; the error at 3× or more.", "budget": null, "experimental": false}, - {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 422, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, - {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 437, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). `dependsOn` is the raw depends_on spec (M1-SYS-02): a comma-separated list of registration names — nullptr or \"\" means no dependencies (see the preamble \"Scheduler\" for the format and the validation). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, - {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 438, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 439, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 440, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::dependsOn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 441, "signature": "const char* dependsOn", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 452, "signature": "struct SystemSchedule", "summary": "The computed execution order of one world's systems (M1-SYS-02). A plain value: built by World::scheduleSystems (setup phase), consumed by World::runSystems once per tick, owned by the caller (the game's engine object — M1-HEAD-01). `systemCount` is the world's system count AT SCHEDULING TIME (runSystems' staleness check); `order[i]` is the SystemId of the system that runs i-th (order[0] first, order[systemCount - 1] last; no repeats, dense 1..systemCount).", "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule::systemCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 453, "signature": "std::uint32_t systemCount{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule::order", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 454, "signature": "std::uint32_t order[kMaxSystems]{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 464, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on and delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()`. The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, - {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 466, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, - {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 475, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, - {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 492, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, - {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 495, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, - {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 505, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 506, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 507, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 511, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 515, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::SystemTimingStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 537, "signature": "struct SystemTimingStats", "summary": "One system's measured-run scalars (M1-SYS-03; PRD §9.3 G-R5): the cheap per-frame snapshot the profiler (M1-PROF-01) and the frame graph (M1-PROF-02) pull through World::systemTimingStats(id) — O(1), no allocation, no side effects. The window SAMPLES are not here (the rolling histogram is read cold through World::systemTimingWindow(id) — its stats() is O(n log n)). All counters are since-construction; the window rolls across ticks (kSystemTimingWindowSamples, not per-frame).", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::runs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 539, "signature": "std::uint64_t runs{}", "summary": "Measured runs of the system since world construction.", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::lastMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 545, "signature": "double lastMs{}", "summary": "The measured time (ms) of the most recent run (0 before the first run). A run shorter than the platform's steady_clock tick measures as exactly 0.0 ms — a legitimate sub-resolution reading (wall-clock resolution is platform-sensitive; ARCH-009), not a failure state.", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::warns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 547, "signature": "std::uint32_t warns{}", "summary": "The system/budget_overrun warns issued since construction.", "budget": null, "experimental": false}, - {"name": "laige::SystemTimingStats::errors", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 550, "signature": "std::uint32_t errors{}", "summary": "The system/budget_critical error events issued since construction.", "budget": null, "experimental": false}, - {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 574, "signature": "#define LAIGE_SYSTEM(Name, budget_ms, ...)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start); the optional trailing `Dep...` names are the depends_on spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified verbatim into the def's `dependsOn` field (comma-separated, as written). Expands to the function declaration plus", "budget": null, "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}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 401, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 404, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 412, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, + {"name": "laige::kMaxSystemDependencies", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 419, "signature": "inline constexpr std::uint32_t kMaxSystemDependencies = 16", "summary": "The bound on one system's direct depends_on list (CORE-005). A direct dependency list is a small hand-written declaration; beyond 16 the ordering should be carried by registration position (a barrier is registration order, not a dependency list). Raising it is an ADR.", "budget": null, "experimental": false}, + {"name": "laige::kSystemTimingWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 426, "signature": "inline constexpr std::uint32_t kSystemTimingWindowSamples = 64", "summary": "The per-system rolling window capacity (M1-SYS-03; CORE-005): the number of measured run times (ms) kept per system in the rolling histogram — ~1.1 s of samples at the default 60 Hz tick rate. The window is fixed at world construction (a setup-path allocation); raising the capacity is an ADR, not a knob.", "budget": null, "experimental": false}, + {"name": "laige::kBudgetCriticalMultiplier", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 432, "signature": "inline constexpr std::uint32_t kBudgetCriticalMultiplier = 3", "summary": "The over-budget multiplier that escalates the budget_overrun warn into a budget_critical error event (M1-SYS-03; PRD §9.3 G-R5: \"over 3× → error event\"; CORE-005). The warn fires strictly above 1× the declared budget; the error at 3× or more.", "budget": null, "experimental": false}, + {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 438, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, + {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 453, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). `dependsOn` is the raw depends_on spec (M1-SYS-02): a comma-separated list of registration names — nullptr or \"\" means no dependencies (see the preamble \"Scheduler\" for the format and the validation). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, + {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 454, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 455, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 456, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::dependsOn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 457, "signature": "const char* dependsOn", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 468, "signature": "struct SystemSchedule", "summary": "The computed execution order of one world's systems (M1-SYS-02). A plain value: built by World::scheduleSystems (setup phase), consumed by World::runSystems once per tick, owned by the caller (the game's engine object — M1-HEAD-01). `systemCount` is the world's system count AT SCHEDULING TIME (runSystems' staleness check); `order[i]` is the SystemId of the system that runs i-th (order[0] first, order[systemCount - 1] last; no repeats, dense 1..systemCount).", "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule::systemCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 469, "signature": "std::uint32_t systemCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule::order", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 470, "signature": "std::uint32_t order[kMaxSystems]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 481, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on, delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()` — and hands the system its PRNG substream (M1-DET-01; PRD §10.3: \"seeded engine PRNG, per-substream\"). The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 483, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::rng", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 498, "signature": "Prng* rng{}", "summary": "The system's PRNG substream (M1-DET-01; PRD §10.3): derived at registration from (the world's seed, this system's SystemId) — the Prng::deriveSubstream contract (laige/prng.h: a pure function of (seed, id), bit-identical across runs; substreams never interleave — a system draws only from its own stream, and the draw order is the call order, the replay state). NON-OWNING: the world owns the stream (its system-registry record); valid only during this system's run (the context's lifetime). nullptr when the world was created with determinism DISABLED (World::Options::deterministic): a system that draws must treat nullptr as \"no random source\" (deterministic mode is the default — S-7 — and every M1 system that wants randomness runs with it on).", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 507, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, + {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 524, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, + {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 527, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 537, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 538, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 539, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 543, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 547, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemTimingStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 569, "signature": "struct SystemTimingStats", "summary": "One system's measured-run scalars (M1-SYS-03; PRD §9.3 G-R5): the cheap per-frame snapshot the profiler (M1-PROF-01) and the frame graph (M1-PROF-02) pull through World::systemTimingStats(id) — O(1), no allocation, no side effects. The window SAMPLES are not here (the rolling histogram is read cold through World::systemTimingWindow(id) — its stats() is O(n log n)). All counters are since-construction; the window rolls across ticks (kSystemTimingWindowSamples, not per-frame).", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::runs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 571, "signature": "std::uint64_t runs{}", "summary": "Measured runs of the system since world construction.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::lastMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 577, "signature": "double lastMs{}", "summary": "The measured time (ms) of the most recent run (0 before the first run). A run shorter than the platform's steady_clock tick measures as exactly 0.0 ms — a legitimate sub-resolution reading (wall-clock resolution is platform-sensitive; ARCH-009), not a failure state.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::warns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 579, "signature": "std::uint32_t warns{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::errors", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 582, "signature": "std::uint32_t errors{}", "summary": "The system/budget_critical error events issued since construction.", "budget": null, "experimental": false}, + {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 606, "signature": "#define LAIGE_SYSTEM(Name, budget_ms, ...)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start); the optional trailing `Dep...` names are the depends_on spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified verbatim into the def's `dependsOn` field (comma-separated, as written). Expands to the function declaration plus", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index e19e278..ea21031 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -161,7 +161,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A ## Determinism & replay -- [ ] **M1-DET-01 · Deterministic mode + sim math rules** +- [x] **M1-DET-01 · Deterministic mode + sim math rules** - **Refs:** FR-1.4, S-7, PRD §10.3; AGENTS ARCH-010 - **Depends:** M0-DEC-02, M0-CORE-03, M0-CORE-04, M0-CORE-06, M1-SYS-01, M1-ECS-05 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index e4d0ce6..b9d9622 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 13 | 🚧 in progress (M1-HEAD-01) | +| M1 | 25 | 14 | 🚧 in progress (M1-DET-01) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **33** | | +| **Total** | **193** | **34** | | --- @@ -208,6 +208,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-LOOP-01 | `30f3013` | Fixed-timestep game loop core (FR-1.1, ARCH-002, PRD §10.2/§10.3; M1-LOOP-01 scope, nothing else): new `GameLoop` (public header `src/laige-sim/include/laige/sim/game_loop.h`, implementation `src/laige-sim/game_loop.cpp`) — the accumulator loop that advances the simulation in INTEGER ticks, decoupled from the presentation frame cadence: `GameLoop::create(world, schedule, options)` validates the typed config (first failure wins; every rejection = `InvalidArgument` + one rate-limited warn, FR-12.3/CORE-008 — `loop/tick_rate_invalid` for `tickRateHz` outside 20–120 (`kMinTickRateHz`/`kDefaultTickRateHz` = 60 / `kMaxTickRateHz`), `loop/catchup_invalid` for `maxCatchUpTicks == 0` (default `kDefaultMaxCatchUpTicks` = 5 — bounds per-frame work, not rate)) and holds non-owning world/schedule views (both outlive the loop; one live loop per world); `frame()` is the hot path (one clock read, a few integer ops, up to `maxCatchUpTicks` BOUNDED `runSystems` dispatches — PERF-002; no allocation, no logging on success — PERF-003/LOG-003) and runs exactly `min(due − ticksRun, maxCatchUpTicks)` ticks where `due(now) = floor(elapsedNs × rate / 10⁹)` is computed in EXACT integer arithmetic (the seconds/sub-seconds split keeps every product overflow-free; no floating point, no rounding drift — ARCH-010) and the unrun remainder is re-derived from the clock every frame (no stored accumulator state: a synthetic 10 s clock at 60 Hz yields EXACTLY 600 ticks — a float ms accumulator floors to 599); the first frame establishes the start reference (zero ticks); `beginFrame()` is driven once per FRAME (the entity.h contract: the G-R3/G-R4 per-frame windows are per presentation frame — a catch-up frame of N ticks counts against one per-frame budget, the documented overload signal) and `runSystems` once per tick; overload: when `want > maxCatchUpTicks` the frame runs exactly `maxCatchUpTicks` and DROPS exactly `want − maxCatchUpTicks` (counted in `droppedTicks`/`droppedFrames` — never silent) with one rate-limited `loop/tick_dropped` warn (NFR-13.3 5-field build-stable message; structured fields `dropped`/`total_dropped`/`max_catch_up`/`tick_rate_hz`; one event per rate window + the `rate_limited` summary at shutdown — LOG-004), and the per-frame work stays bounded so the accumulator never grows unboundedly (PERF-008 backpressure); failure: a stale/malformed schedule surfaces the `runSystems` `InvalidArgument` (`system/schedule_stale`/`schedule_invalid` — the loop adds no event), a failed tick is not counted (its system phase did not complete; no system runs in a failed frame — validation precedes dispatch), the tick count freezes and each later frame fails the same way (rate-limited) until the caller recreates the loop with a recomputed schedule; a moved-from loop is STOPPED (`frame()` → `InvalidArgument`, no log, no world access — the moved-from-world pure-failure precedent) while move transfers the tick state (the factory's `Result` move); the clock source is `Options::nowNs` (nanoseconds on a monotonic epoch time base; `nullptr` → the headless monotonic `steady_clock` — the LoggerOptions::ClockFn precedent; a backward reading below the start reference asserts in debug / clamps in release — never UB); `GameLoopStats` (frames/ticks/droppedTicks/droppedFrames) is the since-construction profiler feed (pure O(1) query — the `World::stats()` precedent; the M1-PROF-01 feed). Determinism scope (ARCH-009/010): the tick sequence is a pure function of (clock readings, rate, cap) — integer-only, bit-identical across builds for the same clock sequence (replay state — M1-DET-01/02 include the tick counter in the hash); clock readings are wall-clock facts (the windowed clock M2-GL-02 / replay runner M1-DET-03 supply the canonical time base); frames/drops are presentation/diagnostic state, never authoritative. New `GameLoop` suite (11 tests) + CTest entry `game_loop` (the step's Verify command; TSan property list): config validation + warns + read-back (the 121 Hz repeat is rate-limited and summarized at shutdown — `suppressed = 1`), the first frame runs zero ticks, exact 600 ticks over a synthetic 10 s clock (400 steps of 16666667 ns + 200 of 16666666 ns = 10¹⁰ ns; machine-greppable `game-loop exact` line), the overload drops EXACTLY 8/16/24 (48 total) over three 10-tick demands against a cap of 2 and logs once per episode (the NFR-13.3 grammar check + the `rate_limited` summary `suppressed = 2`; machine-greppable `game-loop drops` line), the healthy cadence runs 120 ticks / zero drops / silent with one `runSystems` dispatch per tick (the M1-SYS-03 feed tracks the ticks exactly), a stale schedule freezes the tick count and surfaces the `Status` (the `system/schedule_stale` warn rate-limited), a backward clock jump (release clamps to the start reference — no tick, no new event; debug asserts — forked SIGABRT child, POSIX jobs), the default `steady_clock` drives real frames (50 ms sleep → ≥ 3 ticks at 60 Hz), move transfers the state and stops the source (the stopped loop's `frame()` → `InvalidArgument`, no log, world untouched), and the zero-allocation window (300 frames × 2 ticks = 600 ticks, zero drops → `allocs = 0` — the test-only operator-new counter, non-sanitizer trees; machine-greppable `game-loop-zeroalloc` line; the sanitizer trees prove it leak-free). Verified: `ctest -R game_loop` green + full suite 44/44 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (30 source files, 1/10 vendored deps), `laige-api.json` regenerated (505 → 530 symbols; +25: `GameLoop` + members, `Options` + 3 fields, `GameLoopStats` + 4 fields, `kMinTickRateHz`/`kDefaultTickRateHz`/`kMaxTickRateHz`/`kDefaultMaxCatchUpTicks`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/game_loop.md` (the two cadences, the exact due computation, config + validation, the overload behavior, the beginFrame wiring, the failure behavior, the determinism scope, the profiler feed, the Performance section, misuse warnings) + cross-refs in `docs/api/system_timing.md`, `include/laige/sim/system.h` (the scheduler sketch now references `GameLoop`), `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. | 2026-09-14 | M1-LOOP-02 | `7d4cc0d` | Per-tick presentation snapshot + interpolation state (FR-1.1 render interpolation, the 2D-aware half; ARCH-009; PRD §4; M1-LOOP-02 scope, nothing else): `Position2D` — the FIRST built-in component (the entity's 2D simulation-space position as the selected SimMath backend's `Vec2`, ADR 0002; both backends registered: `Position2DFpx16` (fpx16_16, default) / `Position2DFp32` (fp32_pinned, opt-in)) + `PresentationSnapshot` (new header-only public header `src/laige-sim/include/laige/sim/presentation.h` — a class template, one instantiation per backend, the M1-ECS-02 pattern; no new .cpp): the per-completed-tick `prev`/`curr` capture over a pre-reserved per-slot `SlotRecord` table (24 B/slot; one setup-path allocation sized to `world.capacity()`, no per-tick/per-frame heap — PERF-003); NEW entities snap to `curr` (the documented scope behavior: an entity created before the first tick or added between ticks has no end-of-tick T−1 state, so it renders at its spawn position — no phantom interpolation — and interpolates normally from the second tick after creation; the record's stored generation is checked on every refresh, so a slot recycle self-heals — the 2^16 wrap carries the entity-handles' accepted caveat); the snapshot NEVER mutates the world (ARCH-009 — `prev`/`curr` are pure copies of authoritative state); the alpha `alpha = (R − A(T)) × rate / 10⁹` (tick anchor `A(T) = startNs + T × 10⁹/rate` on the loop's time base) is computed in EXACT integer arithmetic (the seconds/remainder split keeps every product overflow-free for any 64-bit clock reading — the `ticksDue` precedent; no float accumulator — ARCH-010) and is CLAMPED to [0, 1] — never extrapolates: before the anchor → 0, a clock jump a full tick or more past the anchor → 1, exact values in between preserved (the sub-second remainder contributes at most `rate − 1` full due ticks, so the branch bounds are overflow-free by construction); it is stored as the backend scalar with one documented rounding per backend (`detail::AlphaConversion`: Fp32Pinned one binary32 division; Fpx16_16 one round-to-nearest into Q16.16 raw) and is a WALL-CLOCK fact — non-deterministic by design, never part of replay state or the simulation state hash (M1-DET-03); `sample_position(e)` (the roadmap's exact name): `lerp(prev, curr, alpha)` (the SimMath backend's lerp, ADR 0002) for a synced entity, the CURRENT value for an entity first seen since the last refresh (snaps), `InvalidArgument` + warn-once `ecs/stale_entity_access` for a stale/invalid handle (the `World::check` precedent — never silent, FR-12.3), `InvalidArgument` with NO warn for a live handle without a `Position2D` (a negative query, like `has()` reading false), and `InvalidArgument` with no world access / no log for a moved-from snapshot (the `GameLoop` moved-out precedent); `create(world, startReferenceNs, options)` validates `tickRateHz` against the loop's documented 20–120 Hz range (first failure wins — `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid`, field `tick_rate_hz`; equality with the driven loop's rate is the engine's wiring guarantee, the preamble's misuse warnings); move-only (an O(1) pointer swap; the moved-from snapshot is STOPPED — every operation fails with `InvalidArgument`, no world access, no logging); the `GameLoop` gains the M1-HEAD-01 wiring seam: `Options::onTick` (a plain `noexcept` function pointer — no std::function, PERF-006 — fired ONCE per COMPLETED tick after the tick's system phase as `onTick(context, world, tick)`, with a failed tick neither counted nor hook-fired) + `onTickContext` + `startReferenceNs()` (the loop's first-frame clock reading — the alpha's anchor base); docs: NEW `docs/api/presentation.md` (full contract + the DOC-004 Performance section), `docs/api/game_loop.md` (the hook preamble section, the Options table row, `startReferenceNs`, the per-tick Performance note), `docs/README.md` (API index + M1 status line), the module README; tests: `tests/laige-sim/presentation_tests.cpp` (suite `Presentation`; CTest entry `presentation` = the step's Verify command), 10 cases — `create` tick-rate validation + the warn shape (memory sink), LINEAR interpolation at exact Q16.16 raw values (alpha 0/0.5/0.75 and the near-1 rounding — raw-unit expectations, no float round-trips; machine-greppable `presentation linear` line), the ALPHA CLAMP matrix (before the anchor / 1 ns either side / a small 0.001 alpha / exactly the next anchor / a half-tick clock jump / 5 s and 16.7 min jumps / a 285-year reading / a below-start reading), ENTITY-ADDED-BETWEEN-TICKS snaps to curr then interpolates normally, CATCH-UP per-tick refresh (one frame, two ticks — the sample uses the LATEST tick's interval), STALE handle rejection (warn-once) + missing-component rejection (no warn), the GAMELOOP HOOK integration (a movement system over `Io` wired through the thunk; `snap.lastTick() == loop.currentTick()` at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + `onTick` + `onRenderFrame` + 100 `sample_position` calls; test-only operator-new counter, machine-greppable `presentation-zeroalloc ... allocs=0`, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); `laige-api.json` regenerated (555 symbols — +24 public symbols: `Position2D`/`Position2DFpx16`/`Position2DFp32`, `PresentationSnapshot` + members, `GameLoop::Options::TickFn`/`onTick`/`onTickContext`, `GameLoop::startReferenceNs`; `api-real-tree` green); local Verify: `ctest -R presentation` green, the canonical g++ tree zero-warning with full `ctest` 45/45, and zero-warning 45/45 on `build-asan`, `build-tsan`, `build-clang`, `build-release`, `build-shared`; `tools/laige-include-lint` OK; Progress Board 12/25 (total 32/193) | | 2026-09-15 | M1-HEAD-01 | `e80ccc8` / PR #31 | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | +| 2026-09-15 | M1-DET-01 | — (working tree; commit pending) | Deterministic mode + sim math rules (FR-1.4, S-7, PRD §10.3; ARCH-010; M1-DET-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/determinism.h` — `SimMathBackend` (`FixedPoint16_16` default / `FloatPinned32`), `DeterminismConfig {enabled, math}`, the G-R8 compile-time trait (`detail::IsDeterminismSafe`: false by default; true for integers, enums, `fpx16_16`, `float` (the fp32_pinned Scalar), the four `SimMath::Vec2/Vec3`; `double` intentionally never safe — no backend uses it), `detail::areDeterminismSafeMembers` (the &&-fold), and `LAIGE_DETERMINISM_SAFE(Type, Members...)` (declares the member list IS the storage; a non-safe member — e.g. `double` — is a compile error AT THE MARK SITE, a new `static_assert` in the specialization, before any system can use the component); the trait is enforced by a third `static_assert` in `World::registerSystem` (entity.h) folding `detail::IoComponentSafety>` over the declared I/O, with an actionable message naming the fix and pointing at the docs; PRNG substreams wired: `World::Options` gains `seed`/`deterministic` (world state carried through create/move/assign; entity.cpp), `registerSystem` derives each system's substream `Prng::deriveSubstream(seed, systemId)` (id 0 = master, never assigned) into `detail::SystemRecord.rng` (`std::optional`), and `runSystems` hands the NON-const record's stream to `SystemContext.rng` (a new `Prng*` field, NSDMI — advanced in place during draws: the stream state IS the replay state); `EngineConfig` appends `seed` (full u64, `kDefaultSimulationSeed = 0`) + `DeterminismConfig determinism` (existing 3-member aggregate inits keep compiling); `parseEngineConfig` gains `seed` (0..2^53 — the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted as 2^53, 2^53+2 is the smallest rejectable value) + the `determinism` object (`enabled` bool, `math` ∈ the two ids; unknown nested key → one `config/unknown_key` warn, ignored — first failure wins across keys) with new rejection events `config/seed_invalid` / `config/determinism_invalid` / `config/determinism_enabled_invalid` / `config/determinism_math_invalid`; `Engine::create` forwards seed + mode to the world and registers the backend-matching built-in FIRST (`Position2DFpx16` / `Position2DFp32`) and builds the presentation snapshot for the same backend (type-erased `detail::PresentationHandle` — one setup allocation, the fnptr-deleter `unique_ptr` idiom, zero added allocations: the headless setup path stays exactly 3); `engine/run_started` gains `seed`/`determinism`/`math` fields (the `laige-run` CLI summary line is unchanged); `tools/laige-determinism-lint` (NEW; Python 3 stdlib, the `laige-include-lint` style) — the sim-source scan over `src/laige-sim/**`: D1a raw `float`/`double` type tokens, D1b float literals, D1c double literals, D2 `unordered_{map,set,multimap,multiset}`, D3 malformed exception markers; a char scanner strips `//`/`/* */` comments, string/char literals, and raw strings before matching (case-sensitive, word-bounded: `Float`/`fromFloat`/`next_float01` do not match); the documented false-positive policy = same-line `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers (15 legitimate in-tree: the M1-SYS-03 wall-clock diagnostics, the presentation alpha conversion, the ADR 0003 JSON number policy, the trait's own `float` registration); every suppressed line is counted + printed (EXC-006: exceptions stay visible in every CI run); exit 0/1/2. Tests: `tests/laige-sim/determinism_tests.cpp` (suites `DeterminismMode`/`DeterminismEngine`/`DeterminismConfigParse`; CTest `determinism_mode` = the step's Verify command) — a trivial moving-entity sim (two entities, `Position2DFpx16` + a marked `DetVel` component, a mover system doing one `ctx.rng->next_range(0,5)` draw per tick at a fixed position + `pos += vel` through SimMathFpx16 ops only) produces BIT-IDENTICAL FNV-1a per-tick state hashes (tick + handle words + raw component words, each<> order) over 256 ticks in two consecutive runs (machine-greppable `determinism-tick-stream` line); a different seed diverges; the system's draws equal an independently constructed `Prng::deriveSubstream(seed, id)` exactly (golden cross-check) and two systems' streams are independent; `deterministic == false` → `ctx.rng == nullptr`; backend selection (fp32 config → `Position2DFp32` duplicate-rejected / `Position2DFpx16` available + 30-tick run completes; default → the inverse); the config keys (defaults, valid values, the rejection table incl. the 2^53 bound exactness, unknown-nested-key forward-compat, first-failure-wins). `tests/laige-sim/compile_fail/` (4 fixtures + `expect-compile-result.cmake.in`, CTest `trait_compile_*`): the positive fixture compiles (exit 0); the three negatives (a `double` member, an unmarked user struct, a `double` in the mark's member list) each FAIL to compile with the G-R8 message (exit-code + stderr-fragment assertions — an incidental compiler error cannot masquerade as the trait). `tests/tools` gains the `determinism-lint-*` fixture tests (clean tree with one marked exception → exit 0; one violation per rule → exit 1; real tree → exit 0) reusing the include-lint pattern; CI: `determinism-lint` job added to BOTH `.github/workflows/ci-pull.yml` and `ci.yml` (ubuntu-24.04, `python3 tools/laige-determinism-lint`). Docs (DOC-007, same change): NEW `docs/concepts/determinism.md` (the ARCH-010 scope statement — what is deterministic, at what scope, verified how, what it is not; the two-layer G-R8 enforcement; the exception policy; the PRNG substreams; the mode table; the provisional config surface) + NEW `docs/api/determinism.md` (the trait API contract) + updates to `docs/api/engine.md` (the seed/determinism keys, backend selection, run_started fields, the determinism scope), `docs/api/system_registry.md` (the `ctx.rng` bullet + the G-R8 validation row), `docs/api/entity.md` (the `World::Options` seed/deterministic fields), `docs/api/sim_math.md` (the G-R8 enforcement note), `docs/testing.md` (the determinism test entries), `docs/concepts/README.md`, `docs/README.md`, `src/laige-sim/README.md`. `laige-api.json` regenerated (573 → 588 symbols; +15: `SimMathBackend` + 2, `DeterminismConfig` + 2, `LAIGE_DETERMINISM_SAFE`, `World::Options` + 2, `SystemContext::rng`, `EngineConfig` + 2, `kDefaultSimulationSeed`; `api-real-tree` green). Verified: `ctest -R determinism_mode` green (14/14), `ctest -R trait_compile` green (4/4), `ctest -R determinism-lint` green (3/3), `python3 tools/laige-include-lint` OK, `python3 tools/laige-determinism-lint` OK (17 files, 15 marked exceptions), full `ctest` 55/55 on `build` (Debug GCC 16.2.1) and 55/55 on `build-asan` (ASan+UBSan leak-free); zero new warnings under NFR-8.10. **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the `seed`/`determinism` keys sit on the PROVISIONAL `parseEngineConfig` surface (documented as provisional in engine.md, the header preamble, and this log line); M1-CFG-01 owns the final versioned schema. | --- diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 3a1a0cd..80a28bd 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -88,11 +88,31 @@ M1-HEAD-01 landed the headless engine run — `Engine` (config → world → systems → loop: `EngineConfig` + the provisional `parseEngineConfig` JSON surface, `run_headless(maxTicks)` the bounded + server run forms, the ordered idempotent CONC-006 -shutdown, the presentation snapshot wiring on the default `fpx16_16` +shutdown, the presentation snapshot wiring on the configured SimMath backend) plus the `laige-run` binary (`--headless`/`--ticks`/ `--replay` stub) and the `laige_run_smoke` CI entry (`include/laige/sim/engine.h`, `engine.cpp`, `tools/run`; API contract in [docs/api/engine.md](../docs/api/engine.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `engine`). -The profiler, determinism/replay, and the remaining M1 steps land -next; physics, input, and animation in M3. +M1-DET-01 landed deterministic mode — the SimMath-only sim +guarantee (the G-R8 compile-time trait +`include/laige/sim/determinism.h`: `SimMathBackend`, +`DeterminismConfig`, `detail::IsDeterminismSafe`, +`LAIGE_DETERMINISM_SAFE`, enforced by a `static_assert` in +`World::registerSystem`), the per-system PRNG substreams +(`World::Options.seed`/`deterministic`, `SystemContext.rng`), the +`seed`/`determinism` keys on the provisional config surface + the +backend selection at engine init (built-in component + snapshot), and +the sim-source determinism scan (`tools/laige-determinism-lint`, the +`determinism-lint` CI job) with same-line +`LAIGE-DETERM-EXCEPTION` markers as the documented false-positive +policy; the scope statement is +[docs/concepts/determinism.md](../docs/concepts/determinism.md), the +trait API in +[docs/api/determinism.md](../docs/api/determinism.md); tests under +[tests/laige-sim](../tests/laige-sim) (CTest entries +`determinism_mode` + `trait_compile_*`; lint fixtures in +[tests/tools](../tests/tools)). +The profiler, replay (M1-DET-02), PRNG state introspection +(M1-DET-03), the detcheck matrix (M1-DET-04), and the remaining M1 +steps land next; physics, input, and animation in M3. diff --git a/src/laige-sim/engine.cpp b/src/laige-sim/engine.cpp index 4b97b0b..2daf0f3 100644 --- a/src/laige-sim/engine.cpp +++ b/src/laige-sim/engine.cpp @@ -81,14 +81,50 @@ inline constexpr const char* kUnknownKeyMessage = "(M1-CFG-01 lands the full declarative schema) | remove the key, " "or wait for M1-CFG-01 | docs/api/engine.md"; +// M1-DET-01: the determinism config keys (seed, determinism.*). +inline constexpr const char* kSeedInvalidMessage = + "seed_invalid | the configured seed is invalid | the value must be " + "an exact integer in 0-2^53 (the ADR 0003 JSON number bound: " + "doubles are exact to 2^53; the programmatic EngineConfig.seed " + "accepts the full 64 bits) | set seed to an integer in 0-2^53 " + "(the default is 0) | docs/api/engine.md"; + +inline constexpr const char* kDeterminismInvalidMessage = + "determinism_invalid | the configured determinism block is invalid " + "| the block must be a JSON object ({} for all defaults); the " + "nested keys enabled (bool) and math (string) have their own " + "checks below | wrap the determinism block in an object | " + "docs/api/engine.md"; + +inline constexpr const char* kDeterminismEnabledInvalidMessage = + "determinism_enabled_invalid | the configured determinism.enabled " + "is invalid | the value must be a JSON boolean (default true — " + "deterministic by default, S-7) | set determinism.enabled to true " + "or false | docs/api/engine.md"; + +inline constexpr const char* kDeterminismMathInvalidMessage = + "determinism_math_invalid | the configured determinism.math is " + "invalid | the value must be the string \"fixed_point_16_16\" " + "(default) or \"float_pinned_32\" (the ADR 0002 backend ids) | " + "set determinism.math to one of the two backend ids | " + "docs/api/engine.md"; + +// The JSON seed bound: the largest value a JSON number can hold +// exactly (doubles are exact integers to 2^53 — ADR 0003). The +// programmatic EngineConfig.seed has no such bound (full uint64). +inline constexpr std::uint64_t kMaxJsonSeed = 1ull << 53; + // True when `value` is a JSON number holding an exact unsigned integer // in [lo, hi] (hi must be <= 2^53, where doubles are exact — ADR 0003 -// number policy); stores the value in `out` on success. +// number policy); stores the value in `out` on success. The double +// here is the JSON number policy's storage type (ADR 0003: numbers +// are parsed to double and must round-trip exactly), not simulation +// math — see the exception markers. bool parseIntInRange(const JsonValue& value, std::uint32_t lo, std::uint32_t hi, std::uint32_t* out) noexcept { if (!value.isNumber()) return false; - const double d = value.asNumber(); - if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || + const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy: doubles store JSON numbers exactly only to 2^53 (ADR 0003); this is config parsing, not sim math + if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math d != std::floor(d)) { return false; } @@ -98,6 +134,23 @@ bool parseIntInRange(const JsonValue& value, std::uint32_t lo, return true; } +// The 64-bit twin for the seed key: an exact unsigned integer in +// [lo, hi] (hi <= 2^53 — the ADR 0003 JSON bound, kMaxJsonSeed). +// Same policy as parseIntInRange: config parsing, not sim math. +bool parseUint64InRange(const JsonValue& value, std::uint64_t lo, + std::uint64_t hi, std::uint64_t* out) noexcept { + if (!value.isNumber()) return false; + const double d = value.asNumber(); // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + if (!std::isfinite(d) || d < 0.0 || d > static_cast(hi) || // LAIGE-DETERM-EXCEPTION: G-R8 JSON number policy (ADR 0003); config parsing, not sim math + d != std::floor(d)) { + return false; + } + const std::uint64_t u = static_cast(d); + if (u < lo) return false; + *out = u; + return true; +} + // A stable machine-searchable name for a JsonValue's kind (the // `value_kind` field of the rejection warns; LOG-001). const char* jsonKindName(const JsonValue& value) noexcept { @@ -175,6 +228,71 @@ Result parseEngineConfig(const JsonValue& doc) noexcept } return ErrorCode::InvalidArgument; } + } else if (key == "seed") { + std::uint64_t seed = 0; + if (!parseUint64InRange(value, 0, kMaxJsonSeed, &seed)) { + if (value.isNumber()) { + LAIGE_LOG_WARN(kConfigSubsystem, "seed_invalid", + kSeedInvalidMessage, + laige::log::field("key", key), + laige::log::field("value", value.asNumber())); + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "seed_invalid", + kSeedInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", jsonKindName(value))); + } + return ErrorCode::InvalidArgument; + } + config.seed = seed; + } else if (key == "determinism") { + if (!value.isObject()) { + LAIGE_LOG_WARN(kConfigSubsystem, "determinism_invalid", + kDeterminismInvalidMessage, + laige::log::field("key", key), + laige::log::field("value_kind", jsonKindName(value))); + return ErrorCode::InvalidArgument; + } + for (const auto& [dkey, dvalue] : value.asObject()) { + if (dkey == "enabled") { + if (!dvalue.isBool()) { + LAIGE_LOG_WARN(kConfigSubsystem, + "determinism_enabled_invalid", + kDeterminismEnabledInvalidMessage, + laige::log::field("key", dkey), + laige::log::field("value_kind", + jsonKindName(dvalue))); + return ErrorCode::InvalidArgument; + } + config.determinism.enabled = dvalue.asBool(); + } else if (dkey == "math") { + if (!dvalue.isString()) { + LAIGE_LOG_WARN(kConfigSubsystem, "determinism_math_invalid", + kDeterminismMathInvalidMessage, + laige::log::field("key", dkey), + laige::log::field("value_kind", + jsonKindName(dvalue))); + return ErrorCode::InvalidArgument; + } + const std::string_view m = dvalue.asString(); + if (m == "fixed_point_16_16") { + config.determinism.math = SimMathBackend::FixedPoint16_16; + } else if (m == "float_pinned_32") { + config.determinism.math = SimMathBackend::FloatPinned32; + } else { + LAIGE_LOG_WARN(kConfigSubsystem, "determinism_math_invalid", + kDeterminismMathInvalidMessage, + laige::log::field("key", dkey), + laige::log::field("value", std::string{m})); + return ErrorCode::InvalidArgument; + } + } else { + // Unknown nested key: WARN (forward-compat) and ignore — the + // M1-CFG-01 rule, applied inside the determinism block too. + LAIGE_LOG_WARN(kConfigSubsystem, "unknown_key", kUnknownKeyMessage, + laige::log::field("key", dkey)); + } + } } else { // Unknown key: WARN (forward-compat) and ignore — the M1-CFG-01 // rule, applied to the provisional surface (never silent). @@ -205,6 +323,12 @@ Result Engine::create(const EngineConfig& config) noexcept { World::Options worldOptions; worldOptions.capacity = config.entityCapacity; worldOptions.churnPerFrameBudget = config.churnPerFrameBudget; + // M1-DET-01 (determinism mode): the master seed and the mode flag + // (the per-system PRNG substreams derive from them at registration + // — registerSystem; the seed is part of the replay identity, + // ADR 0002). + worldOptions.seed = config.seed; + worldOptions.deterministic = config.determinism.enabled; Result worldResult = World::create(worldOptions); if (worldResult.isError()) { // The World's validation (capacity > 65536 -> InvalidArgument, no @@ -215,9 +339,16 @@ Result Engine::create(const EngineConfig& config) noexcept { engine.world_ = std::make_unique(std::move(worldResult).takeValue()); // The engine's built-ins always register FIRST (stable // registration order for the deterministic ComponentTypeIds, - // ARCH-010; the game's components follow through world()). + // ARCH-010; the game's components follow through world()). M1-DET-01: + // the ONE built-in matching the configured SimMath backend is + // registered (ADR 0002, factory-selected at init); the game registers + // the matching Position2D alias for its own systems — registering + // the other alias is a duplicate-component rejection (one alias per + // world, the component.h contract). const Result builtin = - engine.world_->registerComponent(); + config.determinism.math == SimMathBackend::FloatPinned32 + ? engine.world_->registerComponent() + : engine.world_->registerComponent(); if (builtin.isError()) { // Unreachable on a fresh world (the type is registered once per // world); propagated anyway — never silent (CORE-008). @@ -239,9 +370,10 @@ void Engine::onTickHook(void* context, World& world, void Engine::onTickHookDispatch(std::uint64_t tick) noexcept { // The first loop frame runs zero ticks (game_loop.h), so the - // snapshot exists by the time this hook can fire; a null snapshot - // is still a no-op, never a crash. - if (snapshot_ != nullptr) snapshot_->onTick(tick); + // snapshot exists by the time this hook can fire; a missing + // snapshot is still a no-op, never a crash (the handle's + // hasSnapshot() guard — the never-crash contract, CORE-008). + if (snapshot_.hasSnapshot()) snapshot_.onTick(snapshot_.context, tick); } // --------------------------------------------------------------------------- @@ -256,11 +388,22 @@ Status Engine::run_headless(std::uint64_t maxTicks, if (shutDown_ || world_ == nullptr) { return ErrorCode::InvalidArgument; } + // M1-DET-01: the replay-identity fields (the seed and the math + // backend — ADR 0002: both are part of the replay identity). The + // math field carries the ADR 0002 backend id string. LAIGE_LOG_INFO(kEngineSubsystem, "run_started", "Headless run started", laige::log::field("tick_rate_hz", config_.tickRateHz), laige::log::field("tick_target", maxTicks), - laige::log::field("frame_budget_ticks", frameBudgetTicks)); + laige::log::field("frame_budget_ticks", frameBudgetTicks), + laige::log::field("seed", config_.seed), + laige::log::field("determinism", + config_.determinism.enabled), + laige::log::field("math", + config_.determinism.math == + SimMathBackend::FloatPinned32 + ? "fp32_pinned" + : "fpx16_16")); // The schedule is computed ONCE, at the start of the run (the // game's registrations must precede run_headless — the header's // misuse warning; a stale schedule is the loop's documented @@ -284,13 +427,21 @@ Status Engine::run_headless(std::uint64_t maxTicks, // exists before it can fire). const Status firstFrame = loop_->frame(); if (firstFrame.ok()) { - using Snapshot = PresentationSnapshot; - Result snapshotResult = Snapshot::create( - *world_, loop_->startReferenceNs(), - Snapshot::Options{config_.tickRateHz}); + // M1-DET-01: the snapshot of the CONFIGURED SimMath backend + // (ADR 0002, factory-selected at init) — one allocation + // (the snapshot object; its slot table is the run's third + // setup allocation), wrapped in the type-erased handle + // (detail::PresentationHandle — no virtual dispatch, PERF-006). + Result snapshotResult = + config_.determinism.math == SimMathBackend::FloatPinned32 + ? detail::createPresentationHandle( + *world_, loop_->startReferenceNs(), + config_.tickRateHz) + : detail::createPresentationHandle( + *world_, loop_->startReferenceNs(), + config_.tickRateHz); if (snapshotResult.ok()) { - snapshot_ = std::make_unique( - std::move(snapshotResult).takeValue()); + snapshot_ = std::move(snapshotResult).takeValue(); runStatus = runFrames(maxTicks); } else { runStatus = snapshotResult.error(); @@ -334,8 +485,12 @@ Status Engine::runFrames(std::uint64_t maxTicks) noexcept { if (frameStatus.isError()) return frameStatus; // The frame's clock reading goes to the presentation state // (presentation.h wiring: the engine reads the frame clock once - // per frame and passes it to the snapshot). - snapshot_->onRenderFrame(now); + // per frame and passes it to the snapshot). The snapshot exists + // before runFrames runs (created in run_headless) — the guard is + // the never-crash contract (CORE-008). + if (snapshot_.hasSnapshot()) { + snapshot_.onRenderFrame(snapshot_.context, now); + } if (maxTicks != 0 && loop_->currentTick() >= maxTicks) { break; // no sleep after the final tick (a bounded run ends) } diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp index 93b7a89..ff9cc71 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -73,6 +73,8 @@ World::World(World&& other) noexcept iterationActive_(other.iterationActive_), iterationArchetypes_(other.iterationArchetypes_), iterationReadComponents_(other.iterationReadComponents_), + seed_(other.seed_), + deterministic_(other.deterministic_), systems_(std::move(other.systems_)), systemCount_(other.systemCount_), systemTiming_(std::move(other.systemTiming_)) { @@ -112,6 +114,11 @@ World::World(World&& other) noexcept // moved-from world is a valid empty world in every field (no // registry: registerSystem returns InvalidArgument on it). other.systemCount_ = 0; + // M1-DET-01: the determinism state travels with the registry (the + // moved-from world is back at the defaults — seed 0, mode on — and + // registerSystem fails on it anyway: no registry). + other.seed_ = 0; + other.deterministic_ = true; // M1-SYS-03: the per-system timing table travels with the registry // (the move leaves the moved-from world's table null — no timing // state survives the move, like the registry itself). @@ -160,6 +167,9 @@ World& World::operator=(World&& other) noexcept { iterationActive_ = other.iterationActive_; iterationArchetypes_ = other.iterationArchetypes_; iterationReadComponents_ = other.iterationReadComponents_; + // M1-DET-01: the determinism state travels with the registry. + seed_ = other.seed_; + deterministic_ = other.deterministic_; // M1-SYS-01: the system registry travels with the storage. systems_ = std::move(other.systems_); systemCount_ = other.systemCount_; @@ -196,6 +206,10 @@ World& World::operator=(World&& other) noexcept { // M1-SYS-01: the moved-from world is a valid empty world in every // field (no registry: registerSystem returns InvalidArgument on it). other.systemCount_ = 0; + // M1-DET-01: the determinism state is re-taken from `other` above; + // the moved-from world is back at the defaults (seed 0, mode on). + other.seed_ = 0; + other.deterministic_ = true; return *this; } @@ -209,6 +223,11 @@ Result World::create(Options options) noexcept { w.capacity_ = options.capacity; // M1-ECS-06 (G-R3/G-R4): the guardrail configuration (setup path). w.churnPerFrameBudget_ = options.churnPerFrameBudget; + // M1-DET-01 (determinism mode): the master seed and the mode flag + // (setup path — fixed for the world's lifetime; the per-system + // PRNG substreams derive from them at registration, registerSystem). + w.seed_ = options.seed; + w.deterministic_ = options.deterministic; w.initEntityBudgetThresholds(); // Component registry table (M1-ECS-02): the fixed engine-level // budget (kMaxComponentTypes), a setup-path allocation like the diff --git a/src/laige-sim/include/laige/sim/determinism.h b/src/laige-sim/include/laige/sim/determinism.h new file mode 100644 index 0000000..eefe032 --- /dev/null +++ b/src/laige-sim/include/laige/sim/determinism.h @@ -0,0 +1,301 @@ +// laige-sim determinism mode (M1-DET-01). +// +// PRD §10.3 (determinism contract), §9.1 S-7 (deterministic by +// default), §9.3 G-R8 (determinism violation → compile error); +// ADR 0002 (deterministic math strategy); FR-1.4 (determinism mode); +// NFR-8.3 (cross-platform bit-identity verified in CI). +// +// This header carries the M1 determinism surface of laige-sim: +// +// SimMathBackend The two SimMath backends (ADR 0002) as a +// typed config value: FixedPoint16_16 (the +// config id "fixed_point_16_16", the +// default) and FloatPinned32 (the config id +// "float_pinned_32", opt-in). +// DeterminismConfig The typed determinism block of +// EngineConfig: enabled (default true — +// deterministic by default, S-7) and math +// (the selected backend). +// detail::IsDeterminismSafe +// The G-R8 trait: true when T's storage is +// bit-deterministic — every member +// (recursively) is an integer, an enum, a +// SimMath-registered scalar (fpx16_16, +// float), or a SimMath-registered vector +// (SimMath::Vec2/Vec3). The primary +// template is FALSE: an unmarked type is +// unsafe (the compile-time error this step +// ships; the CI source scan below is the +// second, independent enforcement layer). +// LAIGE_DETERMINISM_SAFE The marker: declares that a user struct's +// members are exactly the listed member +// types; the trait then verifies each +// listed type. +// +// --------------------------------------------------------------------------- +// The trait mechanism (G-R8 / S-7: "using raw float/double inside +// deterministic systems is a compile-time/trait error") +// --------------------------------------------------------------------------- +// +// Where the check bites: World::registerSystem (entity.h) — every +// component a system declares I/O for must satisfy +// IsDeterminismSafe (a static_assert with the actionable message). +// M1 systems are deterministic by default (S-7); M1 has no +// non-deterministic system registration path (the first one lands +// with the M2 work, through the documented U-1..U-4 unsafe tier if +// raw math is ever needed outside SimMath). +// +// What the trait checks (and deliberately does not): +// +// - Storage: every member of the component's type is +// determinism-safe storage. `double` is NEVER safe (no SimMath +// backend uses it — ADR 0002); `float` IS safe, because it is +// the fp32_pinned backend's Scalar — its USE must still go +// through SimMath ops, which the CI source scan enforces (the +// trait cannot see usage). +// - Recursion: a nested struct is safe only when ITS type is +// verified the same way — marked (or itself a registered +// scalar/vector/integer/enum). +// - Not usage: "only engine math ops" for the stored values is +// enforced by the source scan plus the ADR 0002 pinned flag set +// (the fp32_pinned backend's exact two-rounding semantics). +// +// Why the marker (not automatic member reflection): standard C++20 +// has no aggregate-member enumeration, and a reflection hack would +// be a clever low-level trick without a demonstrated benefit +// (CPP-018). The marker is explicit, greppable, and safe by default: +// a forgotten mark is a compile error, and the CI source scan +// independently rejects a raw float/double token anywhere in a sim +// translation unit — including a misdeclared struct's `double m;` +// member — so the two layers cover each other's blind spots +// (documented in docs/concepts/determinism.md). +// +// --------------------------------------------------------------------------- +// The CI source scan (tools/laige-determinism-lint) +// --------------------------------------------------------------------------- +// +// The second, independent enforcement layer (PRD §10.3: "no +// unordered containers in sim hot paths"; ADR 0002: no platform +// intrinsics or raw FP outside the engine in sim translation units): +// a textual scan of the sim module's translation units +// (src/laige-sim/** today; laige-core is exempt — it is the +// engine-math home that defines the banned types) rejecting: +// +// D1 raw `float`/`double` type tokens, +// D2 the std::unordered_{map,set,multimap,multiset} container +// tokens, +// +// with comments and string literals stripped (the documented +// false-positive policy — tools/laige-determinism-lint header) and a +// per-line exception marker for the documented off-determinism-path +// uses (wall-clock diagnostics, the presentation alpha conversion, +// the JSON number policy): +// +// // LAIGE-DETERM-EXCEPTION: G-R8 +// +// The scanner validates the marker format (rule id G-R8, non-empty +// reason); a malformed marker is a violation, and every suppressed +// line is counted and reported (EXC-006: new markers need human +// review — they are visible in every CI run and in ctest). +// +// --------------------------------------------------------------------------- +// PRNG substreams (PRD §10.3: "seeded engine PRNG, per-substream; +// seed is part of the replay") +// --------------------------------------------------------------------------- +// +// Each registered system owns a PRNG substream derived from (the +// world's seed, the system's SystemId) — the Prng::deriveSubstream +// contract (laige/prng.h, M0-CORE-06): a pure function of (seed, id), +// so the derivation is bit-identical across runs, and substreams +// never interleave (a system draws only from its own stream; the +// draw order is the call order — the replay state). The world holds +// the streams in its system registry (one std::optional per +// system record — setup-path only); World::Options::seed selects the +// master seed (default 0 — a valid master seed: the Prng's state is +// nonzero for every 64-bit seed, prng.h) and World::Options:: +// deterministic selects whether the streams exist (SystemContext:: +// rng is nullptr when disabled). The seed is part of the replay +// identity (ADR 0002: replay = inputs + seed + math backend + config +// hash). +// +// --------------------------------------------------------------------------- +// Determinism mode semantics (M1 scope; the ARCH-010 scope statement) +// --------------------------------------------------------------------------- +// +// enabled: true (default) +// The engine runs in deterministic mode: the selected SimMath +// backend drives the engine's built-ins (the Position2D +// component and the presentation snapshot, engine.h), and every +// system receives its PRNG substream (SystemContext::rng). The +// simulation state after N completed ticks is a pure function +// of (config, seed, registration order, N, inputs). Verified +// same-build by the `determinism_mode` CTest suite; +// cross-target verification (build/platform/ISA/compiler +// matrix) is M1-DET-04's detcheck matrix. +// enabled: false +// M1 semantics: the engine does NOT create the per-system PRNG +// substreams (SystemContext::rng is nullptr — a system that +// draws must handle nullptr as "no random source"), and the +// run is NOT replayable (no replay identity: the seed and the +// substreams are part of it). Nothing else changes in M1: the +// SimMath-only trait still applies (it is a compile-time fact +// about the types, not a runtime mode), the engine still runs +// the selected backend's built-ins, and no M1 built-in system +// consumes randomness. (The first non-deterministic system +// lands with M2.) +// +// The full promised scope (what is and is not deterministic, and +// under which build/platform conditions) is stated in +// docs/concepts/determinism.md; this header's API contract is in +// docs/api/determinism.md. + +#pragma once + +#include +#include + +#include "laige/sim_math.h" // fpx16_16, SimMath, the backend traits + +namespace laige { + +// The SimMath backend a deterministic run uses (ADR 0002; the typed +// form of the config ids "fixed_point_16_16" / "float_pinned_32"). +// Selected once at engine init (Engine::create consumes it — the +// ADR's "factory-selected at init", no per-call dispatch). +enum class SimMathBackend : std::uint8_t { + // The default backend: Q16.16 in int32_t storage, int64_t + // intermediates (laige/fpx16_16.h). Bit-exact across all builds, + // platforms, ISAs, and compilers (guaranteed by the C++20 language + // standard — ADR 0002); required for lockstep (AC-10.3) and + // authoritative MMO. + FixedPoint16_16, + // Opt-in IEEE float semantics: binary32 (`float`) with the pinned + // flag set (ADR 0002, laige/sim_math.h). Bit-exact across runs of + // the same build on the same platform/ISA; cross-ISA identity is + // the detcheck matrix's job (M1-DET-04) — a failing pair is + // declared unsupported for this backend. + FloatPinned32, +}; + +// The typed determinism block of EngineConfig (M1-DET-01; ADR 0002). +// A plain value: the engine copies it (config echo, engine.h) — no +// ownership, no state, trivially copyable. +struct DeterminismConfig { + // Deterministic mode on/off (S-7: deterministic by default). M1 + // semantics in the header preamble "Determinism mode semantics". + bool enabled{true}; + // The SimMath backend the deterministic run uses (ADR 0002). + SimMathBackend math{SimMathBackend::FixedPoint16_16}; +}; + +namespace detail { + +// IsDeterminismSafe: true when T's STORAGE is bit-deterministic — +// every member (recursively) is an integer, an enum, a +// SimMath-registered scalar, or a SimMath-registered vector. +// +// UNSAFE BY DEFAULT: the primary template is false, so a type with no +// specialization below (and no LAIGE_DETERMINISM_SAFE mark) fails the +// G-R8 check — the compile-time error (S-7: "using raw float/double +// inside deterministic systems is a compile-time/trait error"). +template +struct IsDeterminismSafe { + static constexpr bool value = false; +}; + +// Integers: bit-exact storage on every platform (C++20 two's +// complement — the language-standard guarantee ADR 0002 relies on). +template +struct IsDeterminismSafe>> { + static constexpr bool value = true; +}; + +// Enums: the underlying type is always an integral type (so the +// storage is integer-exact, like the integers above). +template +struct IsDeterminismSafe>> { + static constexpr bool value = true; +}; + +// The SimMath-registered scalars (ADR 0002: one per backend): +// fpx16_16 (the fpx16_16 backend) and float (the fp32_pinned +// backend's binary32 Scalar). `double` is intentionally NOT here: no +// SimMath backend uses it, so it is never determinism-safe storage — +// the compile-time half of G-R8's "compile error". +template <> +struct IsDeterminismSafe { + static constexpr bool value = true; +}; +template <> +struct IsDeterminismSafe { // LAIGE-DETERM-EXCEPTION: G-R8 registers float as the fp32_pinned backend's SimMath-registered Scalar (ADR 0002) — this line declares the registration, it is not a raw-float use + static constexpr bool value = true; +}; + +// The SimMath-registered vector types (one pair per backend): the 2D +// and 3D value types of the two SimMath instantiations +// (laige/sim_math.h). +template <> +struct IsDeterminismSafe::Vec2> { + static constexpr bool value = true; +}; +template <> +struct IsDeterminismSafe::Vec3> { + static constexpr bool value = true; +}; +template <> +struct IsDeterminismSafe::Vec2> { + static constexpr bool value = true; +}; +template <> +struct IsDeterminismSafe::Vec3> { + static constexpr bool value = true; +}; + +// The member-list verifier for the LAIGE_DETERMINISM_SAFE marker: +// true when every listed member type is determinism-safe (the &&-fold +// over an empty pack is its identity: an empty member list is +// vacuously safe). +template +constexpr bool areDeterminismSafeMembers() noexcept { + return (IsDeterminismSafe< + std::remove_cv_t>>::value && + ...); +} + +} // namespace detail + +// Mark Type as a determinism-safe storage/component type (G-R8, +// S-7): declare that Type's members are exactly the listed member +// types (every member; order is irrelevant — the list is a set of +// types). The mark specializes the trait with the verified member +// list: +// +// LAIGE_DETERMINISM_SAFE(MyComponent, fpx16_16, fpx16_16, +// sim::SimMath::Vec2); +// +// Write it once per type, at namespace scope, next to the type +// definition (the LAIGE_COMPONENT precedent, component.h). A type +// that is itself an integer, an enum, or a SimMath-registered +// scalar/vector needs NO mark (the trait covers it directly). A +// member type that is itself a user struct must be marked in turn +// (recursion). The listed types are verified at the mark site: a +// `double` in the list is a compile error here (the static_assert in +// the specialization below), before any system can declare the +// component in its I/O — the failure names the mark, not the first +// system that happened to use the component. +#define LAIGE_DETERMINISM_SAFE(Type, ...) \ + template <> \ + struct laige::detail::IsDeterminismSafe { \ + static_assert( \ + laige::detail::areDeterminismSafeMembers<__VA_ARGS__>(), \ + "LAIGE_DETERMINISM_SAFE(" #Type \ + ", ...): the member list must be determinism-safe — every " \ + "listed type must be an integer, an enum, fpx16_16, float, " \ + "a SimMath Vec2/Vec3, or a marked user struct; a `double` " \ + "member is never legal (no SimMath backend uses it — ADR " \ + "0002). See docs/concepts/determinism.md"); \ + static constexpr bool value = \ + laige::detail::areDeterminismSafeMembers<__VA_ARGS__>(); \ + }; + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/engine.h b/src/laige-sim/include/laige/sim/engine.h index aba2af5..f77dfbe 100644 --- a/src/laige-sim/include/laige/sim/engine.h +++ b/src/laige-sim/include/laige/sim/engine.h @@ -31,11 +31,13 @@ // // 1. Engine::create(config) // Validates the typed config, creates the World (the scene -// budget and churn budget from the config), and registers the -// built-in component Position2DFpx16 FIRST (the engine's -// built-ins always precede the game's components: a stable -// registration order for the deterministic ComponentTypeIds, -// ARCH-010). +// budget, churn budget, seed, and determinism mode from the +// config), and registers the built-in component matching the +// configured SimMath backend (Position2DFpx16 by default, +// Position2DFp32 for float_pinned_32 — M1-DET-01) FIRST (the +// engine's built-ins always precede the game's components: a +// stable registration order for the deterministic +// ComponentTypeIds, ARCH-010). // 2. Game setup (the game's setup phase, on the engine's world): // world->registerComponent(), world->registerSystem(def, // Io<...>...) — the M1 systems are plain C++ functions @@ -115,9 +117,11 @@ // // M1-CFG-01 (unchecked at this step's start) will land the full // declarative config.json: versioned schema, unknown-key handling, -// budgets, camera defaults, asset roots, and the determinism block. -// Until then, parseEngineConfig reads the SUBSET the headless run -// consumes, from an unversioned top-level JSON object: +// budgets, camera defaults, asset roots, and the determinism block's +// final placement (M1-DET-01 lands the seed and determinism keys on +// this PROVISIONAL surface; M1-CFG-01 owns the final schema). Until +// then, parseEngineConfig reads the SUBSET the headless run consumes, +// from an unversioned top-level JSON object: // // "tick_rate_hz" integer, 20..120 (default 60) // "entity_budget" integer, 0..65536 (default 0: no @@ -126,6 +130,19 @@ // scene's declared budget, G-R3) // "churn_per_frame_budget" integer, >= 0 (default 256; 0 // disables the G-R4 guardrail) +// "seed" integer, 0..2^53 (default 0 — the +// ADR 0003 JSON bound: exact doubles to +// 2^53; the programmatic +// EngineConfig.seed accepts the full +// 64 bits. M1-DET-01: the master +// simulation seed, part of the replay +// identity) +// "determinism" object (default {}) +// "enabled" bool (default true — +// deterministic by default, S-7) +// "math" "fixed_point_16_16" | "float_pinned_32" +// (default "fixed_point_16_16" — the +// ADR 0002 backend ids) // // Missing keys take the defaults; unknown keys are WARNED (one // config/unknown_key per key, forward-compat) and ignored; a wrong @@ -138,20 +155,34 @@ // Built-in components and the determinism scope (ARCH-009/010) // --------------------------------------------------------------------------- // -// The engine runs the default SimMath backend (fpx16_16, ADR 0002): -// it registers Position2DFpx16 (presentation.h) and owns a -// PresentationSnapshot. The config's determinism math -// selection (ADR 0002: fpx16_16 default, fp32_pinned opt-in) is -// consumed by M1-DET-01 — until then the backend is the documented -// default, not a config knob. +// M1-DET-01: the engine runs the SimMath backend selected by +// config.determinism.math (ADR 0002, factory-selected at init): +// FixedPoint16_16 (default) registers Position2DFpx16 and owns a +// PresentationSnapshot; FloatPinned32 registers +// Position2DFp32 and owns a PresentationSnapshot +// (presentation.h). The engine holds the snapshot through a +// type-erased handle (detail::PresentationHandle) — one code path, +// no virtual dispatch (PERF-006). The game registers the Position2D +// alias matching its backend for its own systems; registering the +// other alias in the same world is a duplicate-component rejection +// (one alias per world, the component.h contract). // -// The completed tick count of a bounded run is a bounded wall-clock -// fact (the pacing is platform-sensitive — ARCH-009, the game_loop.h -// determinism scope); the simulation STATE after N completed ticks is -// a pure function of (the config, the registration order, the tick -// count): no wall-clock values enter authoritative state. The -// presentation alpha is a wall-clock fact by design (presentation.h: -// never part of replay state or the state hash). +// Determinism mode (S-7, PRD §10.3): with determinism.enabled (the +// default), every registered system receives its PRNG substream +// (SystemContext::rng; derived from (config.seed, system id) — +// determinism.h), and the simulation STATE after N completed ticks +// is a pure function of (the config, the seed, the registration +// order, the tick count, the inputs). With determinism.enabled = +// false, the substreams are not created (SystemContext::rng is +// nullptr) and the run is not replayable — see determinism.h +// "Determinism mode semantics" and docs/concepts/determinism.md. +// +// What is NOT deterministic: the completed tick count of a bounded +// run (the pacing is platform-sensitive — ARCH-009, the game_loop.h +// determinism scope), the presentation alpha (a wall-clock fact by +// design, presentation.h: never part of replay state or the state +// hash), and the timing diagnostics (system.h). No wall-clock values +// enter authoritative state. // // --------------------------------------------------------------------------- // Ownership, threading @@ -213,14 +244,21 @@ #include "laige/errors.h" #include "laige/fpx16_16.h" #include "laige/result.h" +#include "laige/sim/determinism.h" // SimMathBackend, DeterminismConfig (M1-DET-01) #include "laige/sim/entity.h" // World, kDefaultChurnPerFrameBudget #include "laige/sim/game_loop.h" // GameLoop, GameLoopStats, tick-rate constants -#include "laige/sim/presentation.h" // Position2DFpx16, PresentationSnapshot +#include "laige/sim/presentation.h" // Position2D, PresentationSnapshot namespace laige { class JsonValue; // declared in laige/json.h; only a const reference is used +// The default master simulation seed (M1-DET-01; CORE-005). 0 is a +// valid master seed: the Prng's state is nonzero for every 64-bit +// seed (the xorshift128+ state transform — laige/prng.h), so no +// special invalid seed is needed. +inline constexpr std::uint64_t kDefaultSimulationSeed = 0; + // The typed headless-engine configuration (M1-HEAD-01; the provisional // config surface — see the header preamble "The config surface"). // A plain value: the engine copies it into the EngineConfig echo read @@ -237,6 +275,22 @@ struct EngineConfig { // The G-R4 per-frame component-churn budget (0 disables the // guardrail; default kDefaultChurnPerFrameBudget). std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}; + // 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 kDefaultSimulationSeed (0 — a + // valid master seed: the Prng's state is nonzero for every 64-bit + // seed, prng.h). The programmatic path accepts the full 64 bits; + // the JSON config path is bounded to exact integers in 0..2^53 + // (the ADR 0003 number policy — parseEngineConfig's documented + // limit). + std::uint64_t seed{kDefaultSimulationSeed}; + // The determinism block (M1-DET-01; ADR 0002): the mode flag and + // the selected SimMath backend. See the header preamble "The + // config surface" for the JSON keys and "Built-in components and + // the determinism scope" for the semantics; the full promised + // scope is docs/concepts/determinism.md. + DeterminismConfig determinism{}; }; // Load the headless-engine configuration from a parsed JSON document @@ -256,9 +310,19 @@ struct EngineConfig { // "churn_per_frame_budget" not a number, not an exact // integer, or < 0 -> InvalidArgument + warn // (config/churn_budget_invalid) -// unknown key -> Warn only (config/unknown_key, -// forward-compat — M1-CFG-01's -// rule); the key is ignored +// "seed" not a number, not an exact integer, or +// outside 0..2^53 -> InvalidArgument + warn +// (config/seed_invalid) +// "determinism" not an object -> InvalidArgument + warn +// (config/determinism_invalid) +// "determinism.enabled" not a -> InvalidArgument + warn +// boolean (config/determinism_enabled_invalid) +// "determinism.math" not a -> InvalidArgument + warn +// string, or not one of (config/determinism_math_invalid) +// the two ADR 0002 ids +// unknown key (top-level or -> Warn only (config/unknown_key, +// inside "determinism") forward-compat — M1-CFG-01's +// rule); the key is ignored // // Cold path (config load); O(keys), allocates only for the warn // fields. First failure wins; on failure the config is not returned. @@ -266,6 +330,107 @@ struct EngineConfig { [[nodiscard]] Result parseEngineConfig(const JsonValue& doc) noexcept; +namespace detail { + +// The engine's presentation snapshot, type-erased (M1-DET-01): one +// owned snapshot of the configured SimMath backend +// (PresentationSnapshot or +// PresentationSnapshot) behind plain function +// pointers — no virtual dispatch (PERF-006), no std::function, no RTTI +// (the engine-policy laige_apply_engine_policy). The engine holds +// exactly one (a setup-path object — the third of the run's three +// setup allocations, engine.h "Performance"); the dispatch is a +// direct function-pointer call per completed tick / per frame. +// +// Empty (no snapshot) is the pre-run state; the engine's sequence +// guarantees the snapshot exists before onTickHookDispatch can fire +// and before runFrames runs — the checks below are the +// never-crash guards for the unreachable states (CORE-008: a null +// context is a no-op, never a crash). +// The dispatch function-pointer types (the game_loop.h TickFn +// pattern: an alias keeps the member declarations parseable and the +// handle's dispatch a plain function-pointer call). +using PresentationTickFn = void (*)(void* context, std::uint64_t tick); +using PresentationRenderFn = void (*)(void* context, std::int64_t nowNs); + +// The deleter signature of PresentationHandle::storage: a function +// pointer capturing the concrete snapshot type (the factory template +// below provides it per backend; the logging.h stream_ precedent — +// unique_ptr is the engine's type-erasure idiom). +using SnapshotDeleteFn = void (*)(void*); + +struct PresentationHandle { + // The owned snapshot storage (one-shot setup allocation); the + // deleter knows the concrete backend type (the factory template + // below captures it). + std::unique_ptr storage; + // The snapshot as a void pointer for the dispatch (== storage's + // pointer, kept for the direct calls). + void* context{}; + // The snapshot's onTick (world-independent: the snapshot owns its + // non-owning world view, presentation.h). + PresentationTickFn onTick{}; + // The snapshot's onRenderFrame (the frame clock, ns). + PresentationRenderFn onRenderFrame{}; + + // The empty state (no snapshot yet / after reset). User-provided: + // the unique_ptr's own default constructor is deleted for a + // function-pointer deleter ([unique.ptr.singlector]), so the + // empty state is constructed here (the handle is therefore not an + // aggregate — constructed only as Engine::snapshot_ and by the + // factory below; moved, never copied). + PresentationHandle() : storage(nullptr, nullptr) {} + + // True when a snapshot is owned. O(1). + [[nodiscard]] bool hasSnapshot() const noexcept { + return context != nullptr; + } + + // Release the owned snapshot (no-op when empty); used by the + // engine's ordered shutdown (the "pools" step). + void reset() noexcept { + storage.reset(); + context = nullptr; + onTick = nullptr; + onRenderFrame = nullptr; + } +}; + +// Builds the PresentationHandle for one concrete backend +// (PresentationSnapshot): creates the snapshot and wraps it. +// One allocation (the snapshot object — its slot table is the run's +// third setup allocation). The error path is a plain Result error +// (the snapshot's create contract, presentation.h). +template +[[nodiscard]] Result +createPresentationHandle(World& world, std::int64_t startReferenceNs, + std::uint32_t tickRateHz) noexcept { + using Snapshot = PresentationSnapshot; + const typename Snapshot::Options options{tickRateHz}; + Result created = + Snapshot::create(world, startReferenceNs, options); + if (created.isError()) return created.error(); + std::unique_ptr snapshot = + std::make_unique(std::move(created).takeValue()); + PresentationHandle handle; + // The deleter captures the concrete type (the one-shot setup + // allocation's release); context is the same pointer for the + // direct dispatch calls. + handle.storage = std::unique_ptr( + snapshot.release(), + [](void* p) { delete static_cast(p); }); + handle.context = handle.storage.get(); + handle.onTick = [](void* context, std::uint64_t tick) { + static_cast(context)->onTick(tick); + }; + handle.onRenderFrame = [](void* context, std::int64_t nowNs) { + static_cast(context)->onRenderFrame(nowNs); + }; + return handle; +} + +} // namespace detail + // The headless engine (M1-HEAD-01): config -> world -> systems -> // loop, then the ordered CONC-006 shutdown. See the header preamble // for the lifecycle, the run contract, the shutdown order, the config @@ -275,9 +440,11 @@ class Engine { // Setup phase (the engine's only backing allocations happen in the // World's create — the registry tables and, when capacity > 0, the // per-slot tables): validate the typed config, create the World - // (entityCapacity, churnPerFrameBudget), and register the built-in - // Position2DFpx16 (the engine's built-ins always come first — - // ARCH-010). O(1) beyond the World's setup allocations. + // (entityCapacity, churnPerFrameBudget, seed, determinism mode), + // and register the built-in component matching the configured + // SimMath backend (Position2DFpx16 default, Position2DFp32 for + // float_pinned_32 — M1-DET-01; the engine's built-ins always come + // first — ARCH-010). O(1) beyond the World's setup allocations. // // tickRateHz outside 20..120 -> InvalidArgument + warn // (config/tick_rate_invalid) @@ -377,7 +544,10 @@ class Engine { // order). std::unique_ptr world_; std::unique_ptr loop_; - std::unique_ptr> snapshot_; + // The presentation snapshot, type-erased over the configured + // SimMath backend (M1-DET-01; detail::PresentationHandle — no + // virtual dispatch, PERF-006). + detail::PresentationHandle snapshot_{}; // The run's execution order (computed at the start of the run; a // plain value — the SystemSchedule ownership contract, system.h). SystemSchedule schedule_{}; diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index 11ab601..6652af5 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -176,6 +176,7 @@ #include "laige/sim/archetype.h" #include "laige/sim/component.h" +#include "laige/sim/determinism.h" // M1-DET-01: the G-R8 trait check in registerSystem #include "laige/sim/query.h" #include "laige/sim/system.h" @@ -343,6 +344,20 @@ class World { // (ecs/churn_per_frame). Strictly-greater semantics; 0 disables // the guardrail. Default: kDefaultChurnPerFrameBudget. std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}; + // 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). + std::uint64_t seed{0}; + // 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. + bool deterministic{true}; }; // Construction (setup path: the storage's only backing allocations). @@ -820,7 +835,7 @@ class World { // budget) and the system/budget_critical error event (measured at // kBudgetCriticalMultiplier × the budget or more). Called from // runSystems per system per tick (defined in system_timing.cpp). - void checkSystemBudget(std::uint32_t id, double measuredMs) noexcept; + 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-ECS-04 query helpers: compile-time recursion over the listed // components (N ≤ 32 — the M1 bound). Recursion, not a fold: the @@ -972,6 +987,13 @@ class World { bool iterationActive_{false}; detail::IdSet256 iterationArchetypes_{}; detail::IdSet256 iterationReadComponents_{}; + // Determinism mode state (M1-DET-01; determinism.h): the master + // seed and the mode flag, fixed at create() (setup path). The + // per-system PRNG substreams live in the system records' inline + // optional storage (system.h SystemRecord) — no separate table. + // clear() does not touch them (the registry precedent). + std::uint64_t seed_{0}; + bool deterministic_{true}; // System registry (M1-SYS-01; system.h): the fixed engine budget // (kMaxSystems records), indexed by (system id - 1); a dense id is // assigned at registration (registration order, component.h @@ -1079,14 +1101,30 @@ detail::IoResolution World::resolveIoEntry(detail::IdSet256& read, template Result World::registerSystem(const SystemDef& def, Ios...) noexcept { // I/O pack validation (compile time, not runtime surprises): - // every entry must be an Io tag and its component type - // must be a Laige component (FR-1.2, S-8). + // every entry must be an Io tag, its component type + // must be a Laige component (FR-1.2, S-8), and — deterministic mode + // being the default (S-7, M1-DET-01) — every component the system + // declares must be determinism-safe: raw float/double inside + // deterministic systems is a compile-time error (G-R8; the + // double half is the trait, the use half is the CI source scan — + // determinism.h, docs/concepts/determinism.md). static_assert((detail::IsIoTag::value && ...), "registerSystem: every I/O entry must be an " "Io tag value (laige/sim/system.h)"); static_assert((detail::IsIoComponent::value && ...), "registerSystem: every Io component type must " "be marked with LAIGE_COMPONENT(T) (FR-1.2, S-8)"); + static_assert((detail::IoComponentSafety::value && ...), + "registerSystem (M1-DET-01, G-R8/S-7): every " + "component a system declares I/O for must be " + "determinism-safe — its members (recursively) must " + "be integers, enums, SimMath-registered scalars " + "(fpx16_16, float) or vectors (SimMath::Vec2/Vec3), " + "or a user struct marked LAIGE_DETERMINISM_SAFE(Type, " + "MemberTypes...). A `double` member is never legal in " + "deterministic mode (no SimMath backend uses it). See " + "docs/concepts/determinism.md for the trait " + "mechanism and the CI source scan"); if (systems_ == nullptr) { // Moved-from world: no registry (the same "valid empty world" // contract as the component registry, component.h). @@ -1196,6 +1234,17 @@ Result World::registerSystem(const SystemDef& def, Ios...) rec.readComponents = read; rec.writeComponents = write; const std::uint32_t id = systemCount_ + 1; + // M1-DET-01 (PRD §10.3: seeded, per-substream PRNG): derive the + // system's substream from (the world's seed, the system's id). + // The derivation is a pure function of (seed, id) (prng.h), so it + // is bit-identical across runs, and substreams never interleave: + // each system draws only from its own stream, in call order — the + // replay state. Determinism disabled: no stream (the context's + // rng is nullptr — determinism.h "Determinism mode semantics"). + // Inline optional storage: no allocation (setup path). + if (deterministic_) { + rec.rng = Prng::deriveSubstream(seed_, id); + } ++systemCount_; return SystemId{id}; // ids are dense, from 1 } diff --git a/src/laige-sim/include/laige/sim/presentation.h b/src/laige-sim/include/laige/sim/presentation.h index 291e21e..68d36df 100644 --- a/src/laige-sim/include/laige/sim/presentation.h +++ b/src/laige-sim/include/laige/sim/presentation.h @@ -189,6 +189,7 @@ #include "laige/logging.h" #include "laige/result.h" #include "laige/sim_math.h" +#include "laige/sim/determinism.h" // M1-DET-01: the G-R8 marks on both Position2D instantiations #include "laige/sim/entity.h" #include "laige/sim/game_loop.h" @@ -213,8 +214,8 @@ struct AlphaConversion { template <> struct AlphaConversion { - static float toScalar(std::uint32_t alphaNum) noexcept { - return static_cast(alphaNum) / 1e9f; + static float toScalar(std::uint32_t alphaNum) noexcept { // LAIGE-DETERM-EXCEPTION: G-R8 presentation alpha is a wall-clock fact: non-deterministic by design, never enters sim state or the state hash (ARCH-009) + return static_cast(alphaNum) / 1e9f; // LAIGE-DETERM-EXCEPTION: G-R8 presentation alpha is a wall-clock fact: non-deterministic by design, never enters sim state or the state hash (ARCH-009) } }; @@ -262,6 +263,17 @@ struct Position2D { LAIGE_COMPONENT(Position2D); LAIGE_COMPONENT(Position2D); +// M1-DET-01 (G-R8): the built-in is determinism-safe STORAGE — one +// member, the SimMath-registered Vec2 of its backend (determinism.h +// trait). The marks let the built-in appear in a system's declared +// I/O (registerSystem's G-R8 check); without them, every game system +// touching the engine's own position component would fail to +// compile. +LAIGE_DETERMINISM_SAFE( + Position2D, sim::SimMath::Vec2); +LAIGE_DETERMINISM_SAFE( + Position2D, sim::SimMath::Vec2); + // The two backend instantiations: a game registers the one matching // its init-time backend selection (ADR 0002, `determinism.math`). using Position2DFpx16 = Position2D; diff --git a/src/laige-sim/include/laige/sim/system.h b/src/laige-sim/include/laige/sim/system.h index 7bdb999..8c0c2fb 100644 --- a/src/laige-sim/include/laige/sim/system.h +++ b/src/laige-sim/include/laige/sim/system.h @@ -14,8 +14,10 @@ // plain run function, and the declared time budget // in milliseconds (fpx16_16, exact — ADR 0002). // SystemContext The per-tick context handed to a system: the world -// view plus the delegated World::each (the PRD -// Appendix B sketch's `ctx.each<...>()`). +// view, the delegated World::each (the PRD +// Appendix B sketch's `ctx.each<...>()`), and the +// system's PRNG substream (M1-DET-01; nullptr when +// the world's determinism is disabled). // Io One declared component I/O entry of a system: // component type T and its declared access. // SystemInfo A registered system's snapshot (def + the declared @@ -129,6 +131,12 @@ // duplicate name in this world -> InvalidArgument + warn // (system/duplicate) // Io T not a Laige component -> compile error (static_assert) +// Io T not determinism-safe -> compile error (static_assert; +// (G-R8, S-7, M1-DET-01) determinism.h: members must +// be integers/enums, SimMath- +// registered scalars/vectors, or +// LAIGE_DETERMINISM_SAFE-marked +// user structs) // Io T not registered in this // world -> InvalidArgument + warn // (system/io_unregistered) @@ -141,9 +149,14 @@ // // On success the def is COPIED by value into the world's fixed record // table (kMaxSystems records, a setup-path allocation like the -// component registry — the user's def may be a stack variable), and -// the I/O sets are written in place: no allocation at registration -// and none per tick (the registry is read-only during the loop). +// component registry — the user's def may be a stack variable), the +// I/O sets are written in place, and — when the world runs in +// deterministic mode (World::Options::deterministic) — the system's +// PRNG substream is derived into the record: +// Prng::deriveSubstream(the world's seed, the system's id) (M1-DET-01; +// PRD §10.3). No heap allocation at registration and none per tick +// (the substream is inline optional storage; the registry is +// read-only during the loop). // // --------------------------------------------------------------------------- // Scheduler (M1-SYS-02): execution order, depends_on, validation @@ -359,11 +372,14 @@ #include #include +#include #include "laige/budget_harness.h" // M1-SYS-03: the rolling window (Histogram) #include "laige/fpx16_16.h" +#include "laige/prng.h" // M1-DET-01: the per-system PRNG substreams #include "laige/result.h" #include "laige/sim/component.h" +#include "laige/sim/determinism.h" // M1-DET-01: the G-R8 determinism-safety trait #include "laige/sim/query.h" namespace laige { @@ -455,16 +471,32 @@ struct SystemSchedule { }; // The per-tick context handed to a system's run() (FR-1.3; the PRD -// Appendix B sketch's `ctx`). It names the world the system runs on -// and delegates iteration to World::each (query.h) — the sketch's -// `ctx.each<...>()`. The context is built per system per tick by the -// scheduler (M1-SYS-02); until then games and tests build it -// directly. It is a non-owning view (the world owns the storage): -// never store it across ticks. +// Appendix B sketch's `ctx`). It names the world the system runs on, +// delegates iteration to World::each (query.h) — the sketch's +// `ctx.each<...>()` — and hands the system its PRNG substream +// (M1-DET-01; PRD §10.3: "seeded engine PRNG, per-substream"). The +// context is built per system per tick by the scheduler (M1-SYS-02); +// until then games and tests build it directly. It is a non-owning +// view (the world owns the storage): never store it across ticks. struct SystemContext { // The world the system runs on (one world, one owner thread). World& world; + // The system's PRNG substream (M1-DET-01; PRD §10.3): derived at + // registration from (the world's seed, this system's SystemId) — + // the Prng::deriveSubstream contract (laige/prng.h: a pure function + // of (seed, id), bit-identical across runs; substreams never + // interleave — a system draws only from its own stream, and the + // draw order is the call order, the replay state). NON-OWNING: the + // world owns the stream (its system-registry record); valid only + // during this system's run (the context's lifetime). nullptr when + // the world was created with determinism DISABLED + // (World::Options::deterministic): a system that draws must treat + // nullptr as "no random source" (deterministic mode is the default + // — S-7 — and every M1 system that wants randomness runs with it + // on). + Prng* rng{}; + // Delegate to World::each(fn, Read/Write tags...) on the // same world: identical semantics, visit order, iteration-legality // behavior, and Status results (query.h). No allocation. The @@ -542,7 +574,7 @@ struct SystemTimingStats { // measures as exactly 0.0 ms — a legitimate sub-resolution reading // (wall-clock resolution is platform-sensitive; ARCH-009), not a // failure state. - double lastMs{}; + double lastMs{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured run time never enters sim state, hashes, or replays (M1-SYS-03, ARCH-009) // The system/budget_overrun warns issued since construction. std::uint32_t warns{}; // The system/budget_critical error events issued since @@ -585,6 +617,14 @@ struct SystemRecord { SystemDef def{}; IdSet256 readComponents; // declared Read component ids (1..256) IdSet256 writeComponents; // declared Write component ids (1..256) + // The system's PRNG substream (M1-DET-01): set at registration when + // the world runs in deterministic mode (Prng::deriveSubstream(the + // world's seed, the system's id)); empty when determinism is + // disabled (SystemContext::rng is then nullptr). Setup-path state: + // survives clear() (a system is not per-entity data, the registry + // precedent), moves with the world. The optional is inline storage + // (no heap) — the Prng is a 3-word value. + std::optional rng; }; // One per-system timing record (M1-SYS-03): the rolling window of @@ -601,8 +641,9 @@ struct SystemTimingRecord { // constructor, so the record holds it as a unique_ptr — the one // level of indirection is bounded by kMaxSystems). std::unique_ptr window; - double lastMs{}; // most recent measured run (0 before first; a run - // shorter than the steady_clock tick reads 0.0) + // Most recent measured run (0 before the first; a run shorter than + // the steady_clock tick reads 0.0). + double lastMs{}; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic: measured run time never enters sim state, hashes, or replays (M1-SYS-03, ARCH-009) std::uint64_t runs{}; // measured runs since construction std::uint32_t warns{}; // budget_overrun warns issued std::uint32_t errors{}; // budget_critical errors issued @@ -649,6 +690,22 @@ struct IoComponent> { static constexpr Access access = A; }; +// The G-R8 determinism-safety of one declared I/O entry (M1-DET-01): +// true when the tag is an Io whose component type T is +// determinism-safe (IsDeterminismSafe, determinism.h). A NON-Io +// tag reads true here on purpose: the IsIoTag static_assert in +// registerSystem fires first with its own message (the validation +// order is normative — the first failure wins), so this assert never +// needs to name a malformed tag. +template +struct IoComponentSafety { + static constexpr bool value = true; +}; +template +struct IoComponentSafety> { + static constexpr bool value = IsDeterminismSafe::value; +}; + // The parsed form of a depends_on spec (M1-SYS-02). `names[i]` // points into the spec string itself (tokens are substrings — the // spec is a compile-time string literal owned by the program, so the diff --git a/src/laige-sim/system_timing.cpp b/src/laige-sim/system_timing.cpp index b9d7692..22dbd58 100644 --- a/src/laige-sim/system_timing.cpp +++ b/src/laige-sim/system_timing.cpp @@ -89,7 +89,7 @@ const Histogram* World::systemTimingWindow(SystemId id) const noexcept { return systemTiming_[id.value - 1].window.get(); } -void World::checkSystemBudget(std::uint32_t id, double measuredMs) noexcept { +void World::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) // The timing table exists whenever a system is registered // (allocated in create() alongside the registry, moved with it), // and runSystems reaches here only with a non-empty registry — @@ -108,10 +108,10 @@ void World::checkSystemBudget(std::uint32_t id, double measuredMs) noexcept { // The declared budget in ms, converted EXACTLY: fpx16_16 raw / 2^16 // is a power-of-two scale, and the raw range (±2^31) fits the // double mantissa, so the comparison operands are exact. - const double budgetMs = - static_cast(recDef.def.budgetMs.raw) / 65536.0; - const double criticalMs = - static_cast(kBudgetCriticalMultiplier) * budgetMs; + const double budgetMs = // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic (M1-SYS-03, ARCH-009): fpx16_16 raw / 2^16 is a power-of-two scale, exact in the double mantissa + static_cast(recDef.def.budgetMs.raw) / 65536.0; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic (M1-SYS-03, ARCH-009) + const double criticalMs = // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic (M1-SYS-03, ARCH-009) + static_cast(kBudgetCriticalMultiplier) * budgetMs; // LAIGE-DETERM-EXCEPTION: G-R8 wall-clock diagnostic (M1-SYS-03, ARCH-009) if (measuredMs > budgetMs || measuredMs >= criticalMs) { // Cold path — only while the system is over budget. One window diff --git a/src/laige-sim/systems.cpp b/src/laige-sim/systems.cpp index 05d0257..b3edc93 100644 --- a/src/laige-sim/systems.cpp +++ b/src/laige-sim/systems.cpp @@ -418,8 +418,20 @@ Status World::runSystems(const SystemSchedule& schedule) noexcept { } for (std::uint32_t k = 0; k < count; ++k) { const std::uint32_t id = schedule.order[k]; - const detail::SystemRecord& rec = systems_[id - 1]; - SystemContext ctx{*this}; // per-tick, per-system, non-owning + // The record is a NON-const reference on purpose (M1-DET-01): + // the system's PRNG substream is replay state — drawing from it + // advances its stream position in place (the call order IS the + // replay, PRD §10.3). Everything else on the record is read-only + // during the loop (the registry contract). + detail::SystemRecord& rec = systems_[id - 1]; + // M1-DET-01 (PRD §10.3: per-substream PRNG): the context names the + // system's substream — set at registration (registerSystem) when + // the world runs in deterministic mode; nullptr when disabled + // (the system must handle nullptr: no random source). The pointer + // is a per-tick read of the registry record: no allocation. + Prng* rng = + (deterministic_ && rec.rng.has_value()) ? &(*rec.rng) : nullptr; + SystemContext ctx{*this, rng}; // per-tick, per-system, non-owning // M1-SYS-03: the system's own run time (the context is built // outside the window — the measurement is the run function // itself, not the dispatch bookkeeping). diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index c251ad3..92cfaad 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -21,16 +21,17 @@ # gtest_main. The unfiltered entry runs the whole module; the `entity`, # `component_registry`, `archetype`, `query`, `iter_order`, # `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, -# `system_timing`, `game_loop`, `presentation`, and `engine` 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, and M1-HEAD-01 Verify commands (`ctest -R entity`, -# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R -# query`, `ctest -R iter_order`, `ctest -R ecs_guardrails`, -# `ctest -R ecs_stress`, `ctest -R system_registry`, `ctest -R -# scheduler`, `ctest -R system_timing`, `ctest -R game_loop`, -# `ctest -R presentation`, and `ctest -R engine`), selecting exactly -# the suites below from the shared executable. +# `system_timing`, `game_loop`, `presentation`, `engine`, and +# `determinism_mode` 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, and M1-DET-01 Verify +# commands (`ctest -R entity`, `ctest -R component_registry`, +# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`, +# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`, `ctest -R +# system_registry`, `ctest -R scheduler`, `ctest -R system_timing`, +# `ctest -R game_loop`, `ctest -R presentation`, `ctest -R engine`, +# and `ctest -R determinism_mode`), selecting exactly the suites +# below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp @@ -41,7 +42,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp system_timing_tests.cpp game_loop_tests.cpp presentation_tests.cpp - engine_tests.cpp) + engine_tests.cpp + determinism_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, @@ -193,11 +195,65 @@ add_test(NAME engine COMMAND laige-sim_tests --gtest_filter=EngineCreate.*:EngineConfigParse.*:EngineRun.*:EngineShutdown.*) +# M1-DET-01: deterministic mode + SimMath rules (ARCH-010, G-R8, +# ADR 0002). The step's Verify command is `ctest -R determinism_mode`; +# this entry selects exactly the DeterminismMode / DeterminismEngine / +# DeterminismConfigParse suites from the shared laige-sim_tests +# executable (the machine-greppable determinism-tick-stream line lands +# in the ctest output). +add_test(NAME determinism_mode + COMMAND laige-sim_tests + --gtest_filter=DeterminismMode.*:DeterminismEngine.*:DeterminismConfigParse.*) + +# 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 +# compile (exit 0), the negative fixtures must FAIL to compile with +# the actionable G-R8 message (the static_assert in +# World::registerSystem / the LAIGE_DETERMINISM_SAFE mark site). The +# generated check scripts assert exit code + stderr fragment — see +# expect-compile-result.cmake.in for the rationale. +function(laige_add_compile_check name source expect_ok fragment) + if(CMAKE_CXX_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$") + set(_flags "-std=c++20 -Wall -Werror -fno-exceptions -fno-rtti" + " -ffp-contract=off -fno-associative-math") + set(_includes "-I${CMAKE_SOURCE_DIR}/src/laige-sim/include" + "-I${CMAKE_SOURCE_DIR}/src/laige-core/include") + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + set(_flags "/std:c++20 /W4 /WX /EHs- /EHc- /GR-" + " /fp:precise /D _HAS_EXCEPTIONS=0") + set(_includes "/I${CMAKE_SOURCE_DIR}/src/laige-sim/include" + "/I${CMAKE_SOURCE_DIR}/src/laige-core/include") + else() + message(FATAL_ERROR + "no compile-check flags for '${CMAKE_CXX_COMPILER_ID}'") + endif() + set(CXX "${CMAKE_CXX_COMPILER}") + set(CXXFLAGS "${_flags} ${_includes}") + set(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/compile_fail/${source}") + set(OBJ "${CMAKE_CURRENT_BINARY_DIR}/compile_check_${name}.o") + set(EXPECT_OK "${expect_ok}") + set(FRAGMENT "${fragment}") + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/expect-compile-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") +endfunction() + +laige_add_compile_check(trait_compile_ok trait_ok.cpp 1 "") +laige_add_compile_check(trait_compile_reject_double trait_reject_double.cpp + 0 "determinism-safe") +laige_add_compile_check(trait_compile_reject_unmarked trait_reject_unmarked.cpp + 0 "determinism-safe") +laige_add_compile_check(trait_compile_reject_bad_mark trait_reject_bad_mark.cpp + 0 "LAIGE_DETERMINISM_SAFE") + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. 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 PROPERTIES + system_timing game_loop presentation engine determinism_mode PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/compile_fail/trait_ok.cpp b/tests/laige-sim/compile_fail/trait_ok.cpp new file mode 100644 index 0000000..b478fa6 --- /dev/null +++ b/tests/laige-sim/compile_fail/trait_ok.cpp @@ -0,0 +1,46 @@ +// M1-DET-01 compile check, POSITIVE case (must COMPILE — exit 0). +// +// A component whose storage is determinism-safe — integer and +// SimMath-registered scalar members, marked with +// LAIGE_DETERMINISM_SAFE at the mark site (determinism.h) — passes +// the G-R8 static_assert in World::registerSystem (entity.h). This +// fixture is compiled (not linked, not run) by the trait_compile_ok +// CTest check with the engine policy flags. + +#include + +#include "laige/result.h" +#include "laige/sim/determinism.h" +#include "laige/sim/entity.h" +#include "laige/sim/system.h" + +struct DetGood { + std::int32_t a{}; + laige::fpx16_16 b{}; +}; +LAIGE_COMPONENT(DetGood); +// M1-DET-01 (G-R8): the member list IS the type's storage. +LAIGE_DETERMINISM_SAFE(DetGood, std::int32_t, laige::fpx16_16); + +LAIGE_SYSTEM(DetGoodSys, 1) +void DetGoodSys(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx.each( + [](laige::Entity e, const DetGood& g) { + static_cast(e); + static_cast(g); + }, + laige::Read{})); +} + +int main() { + laige::World::Options opts; + opts.capacity = 8; + laige::Result w = + laige::World::create(opts); + if (!w.ok()) return 1; + const laige::Result reg = + std::move(w).takeValue().registerSystem( + DetGoodSys_Def, laige::Io{}); + return reg.ok() ? 0 : 1; +} diff --git a/tests/laige-sim/compile_fail/trait_reject_bad_mark.cpp b/tests/laige-sim/compile_fail/trait_reject_bad_mark.cpp new file mode 100644 index 0000000..aafe0fa --- /dev/null +++ b/tests/laige-sim/compile_fail/trait_reject_bad_mark.cpp @@ -0,0 +1,44 @@ +// M1-DET-01 compile check, NEGATIVE case (must FAIL to compile). +// +// A LAIGE_DETERMINISM_SAFE mark whose member list contains a +// `double`: the mark verifies its member list at the mark site +// (determinism.h) — the static_assert in the specialization fires +// the first time the trait is instantiated (the registerSystem call +// below), BEFORE the component's I/O is checked by the system — the +// fail-early half of the G-R8 trait mechanism. Compiled (not linked) +// by the trait_compile_reject_bad_mark CTest check. + +#include "laige/result.h" +#include "laige/sim/determinism.h" +#include "laige/sim/entity.h" +#include "laige/sim/system.h" + +struct DetBadMark { + double v{}; +}; +LAIGE_COMPONENT(DetBadMark); +// The mark lists the member honestly — and is therefore rejected at +// the mark site (a `double` member is never determinism-safe). +LAIGE_DETERMINISM_SAFE(DetBadMark, double); + +LAIGE_SYSTEM(DetBadMarkSys, 1) +void DetBadMarkSys(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +int main() { + // This call instantiates IsDeterminismSafe (through + // the IoComponentSafety fold in registerSystem), which evaluates + // the mark-site static_assert above. + laige::World::Options opts; + opts.capacity = 8; + laige::Result w = + laige::World::create(opts); + if (!w.ok()) return 1; + const laige::Result reg = + std::move(w).takeValue().registerSystem( + DetBadMarkSys_Def, + laige::Io{}); + return reg.ok() ? 0 : 1; +} diff --git a/tests/laige-sim/compile_fail/trait_reject_double.cpp b/tests/laige-sim/compile_fail/trait_reject_double.cpp new file mode 100644 index 0000000..9c1261e --- /dev/null +++ b/tests/laige-sim/compile_fail/trait_reject_double.cpp @@ -0,0 +1,39 @@ +// M1-DET-01 compile check, NEGATIVE case (must FAIL to compile). +// +// A component whose storage contains a raw `double` is NOT +// determinism-safe: no SimMath backend uses `double` (ADR 0002), and +// the component carries no LAIGE_DETERMINISM_SAFE mark, so the +// primary trait is false. World::registerSystem's G-R8 static_assert +// (entity.h) must fire here with the actionable message pointing at +// docs/concepts/determinism.md. Compiled (not linked) by the +// trait_compile_reject_double CTest check. + +#include "laige/result.h" +#include "laige/sim/determinism.h" +#include "laige/sim/entity.h" +#include "laige/sim/system.h" + +struct DetBad { + double v{}; // raw double — never determinism-safe (G-R8) +}; +LAIGE_COMPONENT(DetBad); +// Intentionally NO LAIGE_DETERMINISM_SAFE mark: the primary trait +// (false) applies to the struct. + +LAIGE_SYSTEM(DetBadSys, 1) +void DetBadSys(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +int main() { + laige::World::Options opts; + opts.capacity = 8; + laige::Result w = + laige::World::create(opts); + if (!w.ok()) return 1; + const laige::Result reg = + std::move(w).takeValue().registerSystem( + DetBadSys_Def, laige::Io{}); + return reg.ok() ? 0 : 1; +} diff --git a/tests/laige-sim/compile_fail/trait_reject_unmarked.cpp b/tests/laige-sim/compile_fail/trait_reject_unmarked.cpp new file mode 100644 index 0000000..2e2c8ef --- /dev/null +++ b/tests/laige-sim/compile_fail/trait_reject_unmarked.cpp @@ -0,0 +1,42 @@ +// M1-DET-01 compile check, NEGATIVE case (must FAIL to compile). +// +// A component whose members are individually determinism-safe (an +// integer) but which carries NO LAIGE_DETERMINISM_SAFE mark: the +// mark is the declaration that the member list IS the type's +// storage, so an unmarked user struct is never safe by default — +// the primary trait is false and World::registerSystem's G-R8 +// static_assert (entity.h) must fire. Compiled (not linked) by the +// trait_compile_reject_unmarked CTest check. + +#include + +#include "laige/result.h" +#include "laige/sim/determinism.h" +#include "laige/sim/entity.h" +#include "laige/sim/system.h" + +struct DetUnmarked { + std::int32_t a{}; // safe member type — but the struct is unmarked +}; +LAIGE_COMPONENT(DetUnmarked); +// Intentionally NO LAIGE_DETERMINISM_SAFE mark. + +LAIGE_SYSTEM(DetUnmarkedSys, 1) +void DetUnmarkedSys(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + ctx.each( + [](laige::Entity e) { static_cast(e); }, laige::Read{}); +} + +int main() { + laige::World::Options opts; + opts.capacity = 8; + laige::Result w = + laige::World::create(opts); + if (!w.ok()) return 1; + const laige::Result reg = + std::move(w).takeValue().registerSystem( + DetUnmarkedSys_Def, + laige::Io{}); + return reg.ok() ? 0 : 1; +} diff --git a/tests/laige-sim/determinism_tests.cpp b/tests/laige-sim/determinism_tests.cpp new file mode 100644 index 0000000..c83190d --- /dev/null +++ b/tests/laige-sim/determinism_tests.cpp @@ -0,0 +1,661 @@ +// laige-sim determinism mode suite (M1-DET-01). +// +// Step Verify scope (roadmap/M1-heartbeat.md, `ctest -R determinism_mode`): +// - a trivial moving-entity sim produces BIT-IDENTICAL per-tick state +// hashes in two consecutive runs (same build, same seed) — the +// same-build verification ARCH-010 promises (cross-target is +// M1-DET-04's detcheck matrix) +// - a different seed DIVERGES (the seed is part of the replay +// identity, ADR 0002) +// - the per-system PRNG substreams match the Prng::deriveSubstream +// golden contract exactly, and substreams are independent +// - determinism disabled (World::Options::deterministic = false) +// yields SystemContext::rng == nullptr (the documented M1 +// semantics, determinism.h) +// - the engine selects the configured SimMath backend (the built-in +// component and the presentation snapshot) +// - the provisional config surface: the seed and determinism keys +// (defaults, valid values, the rejection table) +// +// The hash convention: FNV-1a 64-bit, big-endian byte order per u64 — +// the same constants and convention as the fpx16_16 determinism KAT +// (math_fixed_tests) and the Prng golden vectors (prng_tests). The +// per-tick hash covers (tick, then per entity in each<> order: the +// handle words and the raw component words) — a pure function of the +// tick's state, no addresses, no wall clock (ARCH-010). +// +// The sim in the tests is a trivial mover: one PRNG draw per tick +// (fixed position in the system's run: before the iteration — the +// call order is the replay state) nudges the velocity; each entity +// then integrates position += velocity through SimMathFpx16 ops only +// (G-R8: no raw FP, the SimMath op surface, ADR 0002). + +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/json.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/result.h" +#include "laige/sim/engine.h" +#include "laige/sim/presentation.h" +#include "laige/sim/system.h" + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the +// build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "determinism_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "determinism_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, + "determinism_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test component (global scope: LAIGE_COMPONENT and +// LAIGE_DETERMINISM_SAFE specialize traits at global scope) +// --------------------------------------------------------------------------- + +// The test velocity: SimMath-registered scalars (fpx16_16) — the +// determinism-safe storage the G-R8 trait accepts (determinism.h). +struct DetVel { + laige::fpx16_16 vx{}; + laige::fpx16_16 vy{}; +}; +LAIGE_COMPONENT(DetVel); +// M1-DET-01 (G-R8): the member list IS the type's storage — verified +// by the trait at the mark site. +LAIGE_DETERMINISM_SAFE(DetVel, laige::fpx16_16, laige::fpx16_16); + +// --------------------------------------------------------------------------- +// The trivial mover systems (FR-1.3: plain functions, no class) +// --------------------------------------------------------------------------- + +namespace { + +// Test plumbing: the PRNG draws the systems make (written only on the +// owner thread — PRD §10.2). A fixed array: no allocation in the tick +// path (PERF-003, the test's own zero-alloc discipline). +constexpr std::uint32_t kDetMaxDraws = 512; +std::uint32_t gDetMoveDraws[kDetMaxDraws]; +std::size_t gDetMoveDrawCount = 0; +std::uint32_t gDetTwoDraws[kDetMaxDraws]; +std::size_t gDetTwoDrawCount = 0; +bool gDetRngWasNull = false; + +void resetDetPlumbing() { + gDetMoveDrawCount = 0; + gDetTwoDrawCount = 0; + gDetRngWasNull = false; +} + +} // namespace + +// The mover: one PRNG draw per tick (fixed position: before the +// iteration — the call order is the replay state), then integrates +// position += velocity for every entity through SimMathFpx16 ops only +// (G-R8). The draw nudges the velocity: the ONLY randomness → state +// edge in the sim. +LAIGE_SYSTEM(DetMove, 1) +void DetMove(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + std::uint32_t nudge = 0; + if (ctx.rng != nullptr) { + nudge = ctx.rng->next_range(0, 5); + if (gDetMoveDrawCount < kDetMaxDraws) { + gDetMoveDraws[gDetMoveDrawCount] = nudge; + } + ++gDetMoveDrawCount; + } + const laige::fpx16_16 step = + laige::fpx16_16::fromInt32(static_cast(nudge)); + static_cast(ctx.each( + [step](laige::Entity e, laige::Position2DFpx16& pos, + DetVel& vel) { + static_cast(e); + using M = laige::sim::SimMath; + const M::Vec2 velVec{vel.vx, vel.vy}; + pos.pos = M::add(pos.pos, velVec); // pos += vel (SimMath ops) + vel.vx = laige::fpx16_16::add(vel.vx, step); // the nudge + }, + laige::Write{}, laige::Write{})); +} + +// The second mover (the substream-independence test): draws its OWN +// substream once per tick; touches no state. +LAIGE_SYSTEM(DetTwo, 1) +void DetTwo(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + if (ctx.rng != nullptr) { + if (gDetTwoDrawCount < kDetMaxDraws) { + gDetTwoDraws[gDetTwoDrawCount] = ctx.rng->next_range(0, 5); + } + ++gDetTwoDrawCount; + } + static_cast(ctx.each( + [](laige::Entity e, const laige::Position2DFpx16& p) { + static_cast(e); + static_cast(p); + }, + laige::Read{})); +} + +// The RNG probe (the determinism-disabled test): records that the +// context's rng was nullptr (no random source, the documented M1 +// semantics). +LAIGE_SYSTEM(DetRngProbe, 1) +void DetRngProbe(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + if (ctx.rng == nullptr) gDetRngWasNull = true; + static_cast(ctx.each( + [](laige::Entity e, const laige::Position2DFpx16& p) { + static_cast(e); + static_cast(p); + }, + laige::Read{})); +} + +namespace { + +// --------------------------------------------------------------------------- +// The state hash (ARCH-010: no addresses, no wall clock) +// --------------------------------------------------------------------------- + +// FNV-1a 64-bit, big-endian byte order per u64 (endianness-independent +// — the same convention as the fpx16_16 determinism KAT and the Prng +// golden vectors). +std::uint64_t fnv1a64(const std::uint64_t* values, std::size_t n) { + std::uint64_t h = 0xcbf29ce484222325ull; // FNV offset basis (FNV-1a spec) + for (std::size_t i = 0; i < n; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (values[i] >> shift) & 0xFFull; + h *= 0x100000001b3ull; // FNV prime (FNV-1a spec) + } + } + return h; +} + +// One tick's state hash: (tick, then per entity in each<> order — the +// M1-ECS-05 iteration order: the handle words and the raw component +// words). A pure function of the tick's state. +std::uint64_t hashTick(laige::World& world, std::uint64_t tick) { + std::vector words; + words.push_back(tick); + static_cast(world.each( + [&](laige::Entity e, const laige::Position2DFpx16& pos, + const DetVel& vel) { + words.push_back(e.id); + words.push_back(e.generation); + words.push_back(static_cast( + static_cast(pos.pos.x.raw))); + words.push_back(static_cast( + static_cast(pos.pos.y.raw))); + words.push_back(static_cast( + static_cast(vel.vx.raw))); + words.push_back(static_cast( + static_cast(vel.vy.raw))); + }, + laige::Read{}, laige::Read{})); + return fnv1a64(words.data(), words.size()); +} + +// --------------------------------------------------------------------------- +// The fixture world: two entities, the mover system (plus optionally a +// second drawing system) +// --------------------------------------------------------------------------- + +laige::World makeDetWorld(std::uint64_t seed, bool deterministic, + bool withSecondSystem) { + laige::World::Options opts; + opts.capacity = 64; + opts.seed = seed; + opts.deterministic = deterministic; + laige::Result w = + laige::World::create(opts); + if (!w.ok()) { + ADD_FAILURE() << "World::create failed"; + abort(); + } + laige::World world = std::move(w).takeValue(); + const laige::Result p = + world.registerComponent(); + const laige::Result v = + world.registerComponent(); + const laige::Result s = + world.registerSystem( + DetMove_Def, + laige::Io{}, + laige::Io{}); + if (!p.ok() || !v.ok() || !s.ok()) { + ADD_FAILURE() << "fixture registration failed"; + abort(); + } + if (withSecondSystem) { + const laige::Result s2 = + world.registerSystem( + DetTwo_Def, + laige::Io{}); + if (!s2.ok()) { + ADD_FAILURE() << "second system registration failed"; + abort(); + } + } + // Two entities (fixed initial state — the sim's initial condition): + // e1 at (3, -2) with velocity (1, 0); e2 at (-1, 4) with velocity + // (0, 1). + auto e1r = world.create(); + auto e2r = world.create(); + if (!e1r.ok() || !e2r.ok()) { + ADD_FAILURE() << "fixture entity creation failed"; + abort(); + } + using Pos = laige::Position2DFpx16; + using M = laige::sim::SimMath; + static_cast(world.addComponent( + e1r.value(), Pos{M::Vec2{laige::fpx16_16::fromInt32(3), + laige::fpx16_16::fromInt32(-2)}})); + static_cast( + world.addComponent(e1r.value(), DetVel{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(0)})); + static_cast(world.addComponent( + e2r.value(), Pos{M::Vec2{laige::fpx16_16::fromInt32(-1), + laige::fpx16_16::fromInt32(4)}})); + static_cast( + world.addComponent(e2r.value(), DetVel{laige::fpx16_16::fromInt32(0), + laige::fpx16_16::fromInt32(1)})); + return world; +} + +// Runs n ticks (the schedule once, then runSystems per tick) and +// returns the per-tick state hashes. +std::vector runTicks(laige::World& world, std::uint64_t n) { + laige::SystemSchedule sched; + if (!world.scheduleSystems(sched).ok()) { + ADD_FAILURE() << "scheduleSystems failed"; + abort(); + } + std::vector hashes; + hashes.reserve(n); + for (std::uint64_t t = 1; t <= n; ++t) { + if (!world.runSystems(sched).ok()) { + ADD_FAILURE() << "runSystems failed at tick " << t; + abort(); + } + hashes.push_back(hashTick(world, t)); + } + return hashes; +} + +} // namespace + +// --------------------------------------------------------------------------- +// DeterminismMode: the promised-scope verification (ARCH-010) +// --------------------------------------------------------------------------- + +TEST(DeterminismMode, SameSeedIdenticalTickStreams) { + // Two consecutive runs (same build, same process): the same seed + // must produce bit-identical per-tick state hashes for 256 ticks. + resetDetPlumbing(); + laige::World a = makeDetWorld(42, true, false); + std::vector streamA = runTicks(a, 256); + + resetDetPlumbing(); + laige::World b = makeDetWorld(42, true, false); + std::vector streamB = runTicks(b, 256); + + ASSERT_EQ(streamA.size(), streamB.size()); + for (std::size_t t = 0; t < streamA.size(); ++t) { + if (streamA[t] != streamB[t]) { + ADD_FAILURE() << "tick " << t << " hashes differ: " + << "0x" << std::hex << streamA[t] << " vs 0x" + << streamB[t]; + return; + } + } + // The stream is non-trivial: the state actually moves (the first and + // last tick differ). + EXPECT_NE(streamA.front(), streamA.back()); + // Machine-greppable summary (the CI log of this suite). + std::printf("determinism-tick-stream same-seed ticks=256 " + "first=0x%016llx last=0x%016llx\n", + static_cast(streamA.front()), + static_cast(streamA.back())); +} + +TEST(DeterminismMode, DifferentSeedDiverges) { + // The seed is part of the replay identity (ADR 0002): a different + // seed must change the per-tick state (the substreams differ). + resetDetPlumbing(); + laige::World a = makeDetWorld(42, true, false); + std::vector streamA = runTicks(a, 256); + + resetDetPlumbing(); + laige::World b = makeDetWorld(43, true, false); + std::vector streamB = runTicks(b, 256); + + ASSERT_EQ(streamA.size(), streamB.size()); + bool anyDiffer = false; + for (std::size_t t = 0; t < streamA.size(); ++t) { + if (streamA[t] != streamB[t]) { + anyDiffer = true; + break; + } + } + EXPECT_TRUE(anyDiffer) + << "seeds 42 and 43 produced identical streams — the seed is not " + "reaching the sim (the PRNG substream wiring)"; +} + +TEST(DeterminismMode, SubstreamsMatchPrngDerivation) { + // The system's substream IS Prng::deriveSubstream(seed, systemId): + // the draws the system made must equal the draws of an independently + // constructed Prng with the same derivation (the golden + // cross-check). + resetDetPlumbing(); + laige::World world = makeDetWorld(7, true, false); + runTicks(world, 64); + + ASSERT_EQ(gDetMoveDrawCount, 64u); + laige::Prng golden = laige::Prng::deriveSubstream(7, 1); // system id 1 + for (std::size_t t = 0; t < 64; ++t) { + const std::uint32_t expected = golden.next_range(0, 5); + if (gDetMoveDraws[t] != expected) { + ADD_FAILURE() << "tick " << t << ": system draw " << gDetMoveDraws[t] + << " != deriveSubstream(7,1) draw " << expected; + return; + } + } +} + +TEST(DeterminismMode, SubstreamsAreIndependent) { + // Two systems draw from DIFFERENT substreams (ids 1 and 2): their + // draw sequences must differ (independence — substreams never + // interleave, PRD §10.3). + resetDetPlumbing(); + laige::World world = makeDetWorld(99, true, true); + runTicks(world, 128); + + ASSERT_EQ(gDetMoveDrawCount, 128u); + ASSERT_EQ(gDetTwoDrawCount, 128u); + // Golden cross-checks for BOTH systems. + laige::Prng g1 = laige::Prng::deriveSubstream(99, 1); + laige::Prng g2 = laige::Prng::deriveSubstream(99, 2); + for (std::size_t t = 0; t < 128; ++t) { + EXPECT_EQ(gDetMoveDraws[t], g1.next_range(0, 5)) << "tick " << t; + EXPECT_EQ(gDetTwoDraws[t], g2.next_range(0, 5)) << "tick " << t; + } + // Independence: the two streams differ (128 draws from different + // substreams — a match is a vanishingly small coincidence; a full + // match would be a derivation bug). + bool anyDiffer = false; + for (std::size_t t = 0; t < 128; ++t) { + if (gDetMoveDraws[t] != gDetTwoDraws[t]) { + anyDiffer = true; + break; + } + } + EXPECT_TRUE(anyDiffer) + << "substreams 1 and 2 produced identical draw sequences"; +} + +TEST(DeterminismMode, DeterminismDisabledHasNoSubstream) { + // World::Options::deterministic = false: no substreams are created — + // SystemContext::rng is nullptr (the documented M1 semantics, + // determinism.h). The system draws nothing. + resetDetPlumbing(); + laige::World::Options opts; + opts.capacity = 64; + opts.deterministic = false; + laige::Result wr = + laige::World::create(opts); + if (!wr.ok()) { + ADD_FAILURE() << "World::create failed"; + abort(); + } + laige::World world = std::move(wr).takeValue(); + static_cast(world.registerComponent()); + static_cast(world.registerSystem( + DetRngProbe_Def, + laige::Io{})); + static_cast(world.create()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + for (int t = 0; t < 8; ++t) { + ASSERT_TRUE(world.runSystems(sched).ok()); + } + EXPECT_TRUE(gDetRngWasNull) + << "determinism disabled but the system saw a non-null rng"; +} + +// --------------------------------------------------------------------------- +// DeterminismEngine: the backend selection (ADR 0002) +// --------------------------------------------------------------------------- + +TEST(DeterminismEngine, DefaultBackendIsFixedPoint) { + laige::EngineConfig config; + config.entityCapacity = 16; + // Default: FixedPoint16_16 — the engine registered Position2DFpx16. + laige::Engine engine = + std::move(laige::Engine::create(config)).takeValue(); + laige::World* world = engine.world(); + ASSERT_NE(world, nullptr); + // Duplicate built-in: rejected (one alias per world). + EXPECT_FALSE( + world->registerComponent().ok()); + // The other alias is available to the game. + EXPECT_TRUE(world->registerComponent().ok()); + // A bounded run completes (the fpx16_16 snapshot drives it). + EXPECT_TRUE(engine.run_headless(30, 8).ok()); + engine.shutdown(); +} + +TEST(DeterminismEngine, ConfiguredFloatPinnedBackend) { + laige::EngineConfig config; + config.entityCapacity = 16; + config.determinism.math = laige::SimMathBackend::FloatPinned32; + laige::Result r = + laige::Engine::create(config); + ASSERT_TRUE(r.ok()); + laige::Engine engine = std::move(r).takeValue(); + laige::World* world = engine.world(); + ASSERT_NE(world, nullptr); + // The engine registered the FP32 built-in: the duplicate is rejected, + // the other alias is available. + EXPECT_FALSE( + world->registerComponent().ok()); + EXPECT_TRUE(world->registerComponent().ok()); + // The config echo carries the selection. + EXPECT_EQ(engine.config().determinism.math, + laige::SimMathBackend::FloatPinned32); + // A bounded run completes (the fp32_pinned snapshot drives it). + EXPECT_TRUE(engine.run_headless(30, 8).ok()); + engine.shutdown(); +} + +// --------------------------------------------------------------------------- +// DeterminismConfigParse: the seed and determinism config keys (the +// provisional M1-HEAD-01 surface; M1-CFG-01 owns the final schema) +// --------------------------------------------------------------------------- + +namespace { + +// One log event captured from the facade (the engine_tests.cpp +// MemorySink pattern — Warn+ only, rate limiting off). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + if (record.severity < laige::log::Severity::Warn) return; + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + 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; +} + +// Parses a JSON document from a literal (test cold path). +laige::JsonValue parseDoc(const char* text) { + const laige::Result r = laige::parseJson(text); + if (r.isError()) { + ADD_FAILURE() << "test fixture JSON failed to parse: " << text; + abort(); + } + return r.value(); +} + +} // namespace + +TEST(DeterminismConfigParse, DefaultsAreDeterministicFixedPoint) { + const laige::EngineConfig config = + laige::parseEngineConfig(parseDoc("{}")).value(); + EXPECT_EQ(config.seed, 0u); + EXPECT_TRUE(config.determinism.enabled); + EXPECT_EQ(config.determinism.math, laige::SimMathBackend::FixedPoint16_16); +} + +TEST(DeterminismConfigParse, SeedAndDeterminismValid) { + const laige::EngineConfig config = laige::parseEngineConfig(parseDoc( + R"({"seed":123,"determinism":{"enabled":false,"math":"float_pinned_32"}})")) + .value(); + EXPECT_EQ(config.seed, 123u); + EXPECT_FALSE(config.determinism.enabled); + EXPECT_EQ(config.determinism.math, laige::SimMathBackend::FloatPinned32); +} + +TEST(DeterminismConfigParse, SeedAtTheJsonBoundIsExact) { + // 2^53 is the largest exact-integer double (ADR 0003): accepted. + const laige::EngineConfig config = + laige::parseEngineConfig(parseDoc(R"({"seed":9007199254740992})")) + .value(); + EXPECT_EQ(config.seed, 9007199254740992ull); +} + +TEST(DeterminismConfigParse, SeedRejections) { + MemorySink* sink = installCaptureSink(); + // 2^53+2 (the smallest representable value ABOVE the exact-double + // bound — 2^53+1 itself rounds to 2^53 in the ADR 0003 double and + // is indistinguishable from the bound, so it is accepted as 2^53), + // 1.5 (non-integer), -1 (negative), and a non-number: all + // rejected, one warn each, first-failure-wins. + const char* badDocs[] = { + R"({"seed":9007199254740994})", // 2^53 + 2 + R"({"seed":1.5})", + R"({"seed":-1})", + R"({"seed":"x"})", + }; + for (const char* doc : badDocs) { + const laige::Result r = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(r.isError()) << doc; + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument) << doc; + } + EXPECT_EQ(countEvents(*sink, "seed_invalid"), 4u); + restoreLogger(); +} + +TEST(DeterminismConfigParse, DeterminismRejections) { + MemorySink* sink = installCaptureSink(); + const char* badDocs[] = { + R"({"determinism":true})", // not an object + R"({"determinism":{"enabled":"yes"}})", // not a bool + R"({"determinism":{"math":"fpx16"}})", // unknown backend id + R"({"determinism":{"math":3}})", // not a string + }; + for (const char* doc : badDocs) { + const laige::Result r = + laige::parseEngineConfig(parseDoc(doc)); + ASSERT_TRUE(r.isError()) << doc; + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument) << doc; + } + EXPECT_EQ(countEvents(*sink, "determinism_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "determinism_enabled_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "determinism_math_invalid"), 2u); + restoreLogger(); +} + +TEST(DeterminismConfigParse, UnknownNestedKeyIsForwardCompat) { + // An unknown key inside the determinism block: WARNED (one + // config/unknown_key) and ignored — the M1-CFG-01 rule. + MemorySink* sink = installCaptureSink(); + const laige::Result r = + laige::parseEngineConfig( + parseDoc(R"({"determinism":{"unknown_future_key":1}})")); + ASSERT_TRUE(r.ok()); + EXPECT_EQ(countEvents(*sink, "unknown_key"), 1u); + restoreLogger(); +} + +TEST(DeterminismConfigParse, FirstFailureWinsAcrossBlocks) { + // A bad seed AND a bad determinism block: exactly ONE rejection + // (the seed — document order), one warn. + MemorySink* sink = installCaptureSink(); + const laige::Result r = + laige::parseEngineConfig( + parseDoc(R"({"seed":-1,"determinism":{"math":"nope"}})")); + ASSERT_TRUE(r.isError()); + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(countEvents(*sink, "seed_invalid"), 1u); + EXPECT_EQ(countEvents(*sink, "determinism_math_invalid"), 0u); + restoreLogger(); +} diff --git a/tests/laige-sim/expect-compile-result.cmake.in b/tests/laige-sim/expect-compile-result.cmake.in new file mode 100644 index 0000000..a70d9a0 --- /dev/null +++ b/tests/laige-sim/expect-compile-result.cmake.in @@ -0,0 +1,48 @@ +# Generated by tests/laige-sim/CMakeLists.txt for one G-R8 trait +# compile-check fixture (M1-DET-01) — do not edit. +# +# Compiles one fixture translation unit with the engine policy flags +# (the same flag set laige_apply_engine_policy + +# laige_apply_simmath_policy put on the sim targets) and asserts +# BOTH the exit code and — for the negative fixtures — a required +# stderr fragment: the failure must be the actionable G-R8 message, +# not an incidental error in the fixture. The assertions live here, +# in a CMake script, instead of in CTest properties: CTest inverts +# PASS_REGULAR_EXPRESSION when WILL_FAIL is set (verified on CMake +# 4.4.3 — a matching regex then makes the test fail), and a plain +# exit-code expectation would let a compiler crash (also non-zero) +# masquerade as a correct compile failure. +# +# The script exits non-zero (failing the CTest test) on any mismatch +# and prints the full compiler output for diagnosis. + +execute_process( + COMMAND @CXX@ @CXXFLAGS@ -c "@SOURCE@" -o "@OBJ@" + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err +) + +set(_text "${_out} +${_err}") + +set(_problems "") +if(@EXPECT_OK@) + if(NOT _rc EQUAL 0) + set(_problems "exit code ${_rc} (expected 0 — the fixture must compile)") + endif() +else() + if(_rc EQUAL 0) + set(_problems "exit code 0 (expected a compile failure)") + endif() + if(NOT _text MATCHES "@FRAGMENT@") + string(APPEND _problems + "compiler output missing the G-R8 fragment '<@FRAGMENT@>'; ") + endif() +endif() +if(NOT _problems STREQUAL "") + message(FATAL_ERROR + "G-R8 compile check failed [${_problems}]\n" + "--- compiler output ---\n${_text}\n--- end of compiler output ---") +endif() +message("G-R8 compile check OK: @SOURCE@ @EXPECT_OK@=expected result") diff --git a/tests/laige-sim/scheduler_tests.cpp b/tests/laige-sim/scheduler_tests.cpp index 66ac788..0d688a9 100644 --- a/tests/laige-sim/scheduler_tests.cpp +++ b/tests/laige-sim/scheduler_tests.cpp @@ -106,16 +106,22 @@ struct SchPos { std::int32_t y; }; LAIGE_COMPONENT(SchPos); +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(SchPos, std::int32_t, std::int32_t); struct SchVel { std::int64_t vx; }; LAIGE_COMPONENT(SchVel); +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(SchVel, std::int64_t); struct SchTag { std::int32_t v; }; LAIGE_COMPONENT(SchTag); +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(SchTag, std::int32_t); // --------------------------------------------------------------------------- // The plain systems (FR-1.3: plain functions, no class, no diff --git a/tests/laige-sim/system_registry_tests.cpp b/tests/laige-sim/system_registry_tests.cpp index 10e9554..708e6d7 100644 --- a/tests/laige-sim/system_registry_tests.cpp +++ b/tests/laige-sim/system_registry_tests.cpp @@ -92,17 +92,23 @@ struct SysTestPos { std::int32_t y; }; LAIGE_COMPONENT(SysTestPos); +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(SysTestPos, std::int32_t, std::int32_t); struct SysTestVel { std::int64_t vx; }; LAIGE_COMPONENT(SysTestVel); +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(SysTestVel, std::int64_t); struct SysTestHealth { std::int32_t current; std::int32_t max; }; LAIGE_COMPONENT(SysTestHealth); +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(SysTestHealth, std::int32_t, std::int32_t); // --------------------------------------------------------------------------- // The plain systems (FR-1.3: plain functions, no class, no diff --git a/tests/laige-sim/system_timing_tests.cpp b/tests/laige-sim/system_timing_tests.cpp index 9b5d956..b48b86a 100644 --- a/tests/laige-sim/system_timing_tests.cpp +++ b/tests/laige-sim/system_timing_tests.cpp @@ -86,6 +86,8 @@ struct STTag { std::int32_t v{}; }; LAIGE_COMPONENT(STTag) +// M1-DET-01 (G-R8): integer-only storage (determinism.h trait). +LAIGE_DETERMINISM_SAFE(STTag, std::int32_t) namespace { diff --git a/tests/tools/CMakeLists.txt b/tests/tools/CMakeLists.txt index 0651a4d..9fbe496 100644 --- a/tests/tools/CMakeLists.txt +++ b/tests/tools/CMakeLists.txt @@ -196,3 +196,86 @@ laige_add_lint_test(include-lint-unknown-module 2 "${FX}/unknown-module" "src/laige-audio: not a PRD") laige_add_lint_test(include-lint-real-tree 0 "${CMAKE_SOURCE_DIR}" "laige-include-lint: OK") + +# =========================================================================== +# determinism-source-scan tests (M1-DET-01) +# +# tools/laige-determinism-lint is a plain-Python (stdlib-only) CI script +# that scans the sim module for raw float/double (D1) and unordered +# containers (D2), with per-line LAIGE-DETERM-EXCEPTION markers as the +# documented false-positive policy. Same fixture + generated-`cmake -P` +# pattern as the include-lint tests above. +# =========================================================================== + +set(DFX "${CMAKE_BINARY_DIR}/det-lint-fixtures") +file(REMOVE_RECURSE "${DFX}") + +# --- fixture: clean sim tree, one marked exception (expect exit 0) ------ +file(MAKE_DIRECTORY "${DFX}/clean/src/laige-sim") +file(WRITE "${DFX}/clean/src/laige-sim/sim.cpp" + "#include +// a comment that mentions float and double is NOT code (stripped). +int step(int x, int v) { return x + v; } +double diagnosticMs = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 documented off-path use (fixture) +") + +# --- fixture: one violation per rule D1a/D1b/D1c/D2/D3 (expect exit 1) --- +file(MAKE_DIRECTORY "${DFX}/violations/src/laige-sim") +file(WRITE "${DFX}/violations/src/laige-sim/d1a.cpp" + "float a; // raw float type token (D1a) +") +file(WRITE "${DFX}/violations/src/laige-sim/d1b.cpp" + "int b = 0.5f; // raw float literal (D1b) +") +file(WRITE "${DFX}/violations/src/laige-sim/d1c.cpp" + "int c = 65536.0; // raw double literal (D1c) +") +file(WRITE "${DFX}/violations/src/laige-sim/d2.cpp" + "#include +// unordered_map m; // raw unordered container (D2) +") +# D3: a half-written marker (missing the G-R8 rule id). +file(WRITE "${DFX}/violations/src/laige-sim/d3.cpp" + "float d; // LAIGE-DETERM-EXCEPTION: not-G-R8 malformed +") +# These must NOT be flagged (the false-positive policy): +# - float/double in a comment or string literal (stripped), +# - identifier prefixes (fromFloat, next_float01) are not type tokens, +# - a valid same-line marker suppresses the line. +file(WRITE "${DFX}/violations/src/laige-sim/ok.cpp" + "// float double unordered_map in a comment: not code. +const char* note = \"float double unordered_map in a string\"; +int fromFloat(int x) { return x; } +int next_float01(int x) { return x; } +double marked = 0.0; // LAIGE-DETERM-EXCEPTION: G-R8 documented off-path use +") + +# --- a shared generated-check template for the determinism lint ---------- +function(laige_add_det_lint_test name expect_exit root) + set(_checks "") + foreach(_needle IN LISTS ARGN) + string(REPLACE "\\" "\\\\" _esc "${_needle}") + string(REPLACE "\"" "\\\"" _esc "${_esc}") + string(APPEND _checks + "if(NOT _text MATCHES \"${_esc}\")\n" + " string(APPEND _problems \"output missing '<${_esc}'>; \")\n" + "endif()\n") + endforeach() + set(PYTHON "${LAIGE_LINT_PYTHON}") + set(LINT "${CMAKE_SOURCE_DIR}/tools/laige-determinism-lint") + set(ROOT "${root}") + set(EXPECT_EXIT "${expect_exit}") + set(CHECKS "${_checks}") + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-det-lint-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") +endfunction() + +# (name, expected exit, tree, required output fragments...) +laige_add_det_lint_test(determinism-lint-clean 0 "${DFX}/clean" + "laige-determinism-lint: OK" "1 suppressed line") +laige_add_det_lint_test(determinism-lint-violations 1 "${DFX}/violations" + "D1a" "D1b" "D1c" "D2" "D3 malformed") +laige_add_det_lint_test(determinism-lint-real-tree 0 "${CMAKE_SOURCE_DIR}" + "laige-determinism-lint: OK") diff --git a/tests/tools/expect-det-lint-result.cmake.in b/tests/tools/expect-det-lint-result.cmake.in new file mode 100644 index 0000000..fa71bda --- /dev/null +++ b/tests/tools/expect-det-lint-result.cmake.in @@ -0,0 +1,35 @@ +# Generated by tests/tools/CMakeLists.txt for one determinism-lint CTest +# test (M1-DET-01) — do not edit. +# +# Runs tools/laige-determinism-lint against one tree and asserts BOTH the +# exit code and the required output fragments (the needle checks below are +# generated per test). The assertions live here, in a CMake script, +# instead of in CTest properties: CTest inverts PASS_REGULAR_EXPRESSION +# when WILL_FAIL is set (verified on CMake 4.4.3 — a matching regex then +# makes the test fail), and a plain exit-code expectation would let an +# interpreter crash (also exit 1) masquerade as a correct lint failure. +# +# The script exits non-zero (failing the CTest test) on any mismatch and +# prints the full lint output for diagnosis. + +execute_process( + COMMAND @PYTHON@ "@LINT@" --root "@ROOT@" + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err +) + +set(_text "${_out} +${_err}") + +set(_problems "") +if(NOT _rc EQUAL @EXPECT_EXIT@) + set(_problems "exit code ${_rc} (expected @EXPECT_EXIT@)") +endif() +@CHECKS@ +if(NOT _problems STREQUAL "") + message(FATAL_ERROR + "laige-determinism-lint check failed [${_problems}]\n" + "--- lint output ---\n${_text}\n--- end of lint output ---") +endif() +message("laige-determinism-lint check OK: exit @EXPECT_EXIT@, required output present") diff --git a/tools/README.md b/tools/README.md index 8640b98..4b35ab7 100644 --- a/tools/README.md +++ b/tools/README.md @@ -24,6 +24,19 @@ Engine tools and CI scripts, each landing with its roadmap step: vendored deps only from their `deps.lock` owner) and fails above the PRD §11 dependency budget of 10. Runs in CI on every PR and merge (job `include-lint`), and as CTest checks in `tests/tools`. +- `laige-determinism-lint` — sim-source determinism scan (M1-DET-01; + the second half of the G-R8 guarantee — the first is the compile-time + trait in `World::registerSystem`). Pure Python 3 stdlib; run it as + `python3 tools/laige-determinism-lint [--root REPO_ROOT]`. Scans + `src/laige-sim/**` for raw `float`/`double` (type tokens, float and + double literals) and `unordered_*` containers, with same-line + `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers as the documented + false-positive policy (every suppressed line is counted and printed). + Exit codes: `0` pass · `1` violation · `2` structural. Runs in CI on + every PR and merge (job `determinism-lint`), and as CTest checks in + `tests/tools` (fixture trees + the real tree). Scope, rules, and the + exception policy: + [docs/concepts/determinism.md](../docs/concepts/determinism.md). - `laige-api` — public API manifest generator (M0-TOOL-01) - `laige-detcheck` — determinism checker skeleton (M0-TOOL-02, in `tools/detcheck`): runs a named scenario in two build configurations diff --git a/tools/laige-determinism-lint b/tools/laige-determinism-lint new file mode 100755 index 0000000..c7d1580 --- /dev/null +++ b/tools/laige-determinism-lint @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +# ============================================================================ +# laige-determinism-lint — sim-source determinism scan (M1-DET-01) +# +# Roadmap: roadmap/M1-heartbeat.md, step M1-DET-01 +# PRD refs: §10.3 (determinism contract: "the simulation code uses only +# engine math ops; using raw float/double inside deterministic +# systems is a compile-time/trait error"; "no unordered +# containers in sim hot paths"), §9.1 S-7 (deterministic by +# default), §9.3 G-R8 (determinism violation → compile error) +# ADR refs: 0002 (deterministic math strategy: no raw FP / platform +# intrinsics outside SimMath in sim translation units), 0003 +# (JSON numbers are doubles — the config-parse boundary) +# AGENTS: ARCH-010 (determinism scope stated + verified in CI), +# TEST-004 (determinism tests compare state hashes/replay +# outcomes), CORE-008 (no silent failure) +# +# Usage: +# python3 tools/laige-determinism-lint [--root REPO_ROOT] +# +# REPO_ROOT defaults to the current directory. CI runs it from the repo +# root on every pull request (job `determinism-lint` in +# .github/workflows/ci-pull.yml) and on every merge (ci.yml); it is also +# registered as CTest tests in tests/tools (fixture trees + real tree). +# +# What it does +# ------------ +# A second, independent enforcement layer for M1-DET-01 — the first is +# the compile-time trait (laige/sim/determinism.h, checked in +# World::registerSystem). The two layers cover each other's blind +# spots: the trait checks the STORAGE of every component a system +# declares I/O for; this scan checks the SOURCE of every sim +# translation unit, including code a trait cannot see (a helper's +# local `double`, an off-trait-path diagnostic). +# +# Scope: the sim module's translation units only — src/laige-sim/** +# (headers and sources). laige-core is EXEMPT: it is the engine-math +# home that defines the banned types (laige/sim_math.h declares the +# fp32_pinned `float` Scalar, laige/fpx16_16.h, laige/prng.h). +# tests/ and tools/ are out of scope (test fixtures may model anything). +# +# Rules (a violation is one finding per offending line): +# +# D1 raw floating-point in a sim translation unit: +# D1a the type tokens `float` / `double` (word-boundary match), +# D1b float literals (numeric with an `f`/`F` suffix, e.g. +# `1e9f`, `0.5F`), +# D1c double literals (numeric with a `.` or exponent and no +# integer/floating suffix, e.g. `0.5`, `1e9`, `65536.0`). +# D2 unordered containers: `unordered_map`, `unordered_set`, +# `unordered_multimap`, `unordered_multiset` (PRD §10.3: no +# unordered containers in sim hot paths; a deterministic +# container, if ever needed, gets an ADR first). +# D3 a malformed exception marker (below). +# +# Exception markers (the documented false-positive / legitimate-off-path +# policy — see docs/concepts/determinism.md "Source scan and +# exceptions"): a line is SUPPRESSED when it carries a same-line marker +# +# // LAIGE-DETERM-EXCEPTION: G-R8 +# +# The marker must name the rule id `G-R8` and carry a non-empty reason; +# a line containing `LAIGE-DETERM-EXCEPTION` that does not match that +# format is itself a violation (D3) — a marker cannot be half-written. +# Every suppressed line is counted and reported in the summary +# (EXC-006: exceptions stay visible — they appear in every CI run and +# in ctest, so new markers need human review by construction). +# +# The current legitimate uses (all marked in-tree): wall-clock +# diagnostics (the M1-SYS-03 system-timing doubles — ARCH-009: they +# never enter sim state, hashes, or replays), the presentation alpha +# conversion (a wall-clock fact by design, ARCH-009), the JSON number +# policy (ADR 0003: config parsing stores numbers as doubles and must +# round-trip them exactly), and the trait's own registration of +# `float` as the fp32_pinned backend's SimMath-registered Scalar +# (ADR 0002 — the declaration of the registration, not a raw-float +# use). +# +# False-positive policy (documented, not bugs): the scan is textual and +# STRIPS comments, string/char literals, and raw strings before +# matching — a `float` in a comment or a string literal is NOT a +# violation (documentation is not code). Macro expansions are scanned +# where they are written: a `LAIGE_SYSTEM(Name, 0.5)` budget literal in +# a sim translation unit IS a D1c finding (a raw double constant in the +# sim source, even though the macro converts it to fpx16_16 once at +# setup) — mark it if the conversion is the documented use. Token +# matching is case-sensitive and word-bounded: `Float`, `fromFloat`, +# `next_float01`, `toFloat` do not match (the identifier is not the +# type token). +# +# Exit codes: 0 = pass · 1 = D1/D2/D3 violation · 2 = structural failure +# (missing src/laige-sim, unreadable file). +# +# Python 3 stdlib only; no third-party imports (DEP policy: the lint +# must run on every P0 runner image without setup — the +# laige-include-lint precedent). +# ============================================================================ + +import re +import sys +from pathlib import Path + +SOURCE_EXTS = {".h", ".hpp", ".hh", ".hxx", ".inl", ".tpp", + ".c", ".cc", ".cpp", ".cxx"} + +# D1a: the banned type tokens (word-boundary, case-sensitive). +TYPE_TOKEN = re.compile(r"\b(double|float)\b") +# D1b: float-suffixed numeric literals (0.5f, 1e9F, 1.f, 0x1p-2f). +# The numeric part matches the C++ decimal/hex literal shapes; the +# trailing (?![\w.]) rejects a longer token (an `f`-suffixed literal +# is the whole token). +FLOAT_LITERAL = re.compile( + r"\b(?:" + r"0[xX][0-9a-fA-F]*\.?[0-9a-fA-F]*[pP][+-]?\d+" # hex float + r"|\d+\.\d*(?:[eE][+-]?\d*)?" # dotted decimal + r"|\.\d+(?:[eE][+-]?\d*)?" # leading-dot decimal + r"|\d+[eE][+-]?\d+" # exponent decimal + r")[fF](?![\w.])") +# D1c: double literals — the same numeric shapes without a +# float/integer suffix (0.5, 1e9, 65536.0, 1e-3, 0x1p-3). The trailing +# (?![\w.]) keeps 1.0f / 1e9l / 1.0 from matching (those belong to +# D1b or are integer-adjacent tokens). +DOUBLE_LITERAL = re.compile( + r"\b(?:" + r"0[xX][0-9a-fA-F]*\.?[0-9a-fA-F]*[pP][+-]?\d+" + r"|\d+\.\d*(?:[eE][+-]?\d*)?" + r"|\.\d+(?:[eE][+-]?\d*)?" + r"|\d+[eE][+-]?\d+" + r")(?![\w.])") +# D2: the unordered containers (word-boundary). +UNORDERED = re.compile(r"\bunordered_(map|set|multimap|multiset)\b") + +# The exception marker (docs/concepts/determinism.md): the marker id, +# the rule id, and a non-empty reason on the same line. +MARKER = re.compile(r"LAIGE-DETERM-EXCEPTION\s*:\s*G-R8\s+(.*)$") +MARKER_ID = "LAIGE-DETERM-EXCEPTION" + +# Raw string: R"delim( ... )delim" (delimiter ≤ 16 chars — [lex.string]). +RAW_DELIM_MAX = 16 + + +class Failure(Exception): + """Structural failure: process exits with code 2.""" + + +def fail(message): + raise Failure(message) + + +def find_root(explicit): + root = Path(explicit if explicit else ".").resolve() + if not (root / "src" / "laige-sim").is_dir(): + fail(f"repository root not found: need src/laige-sim/ under " + f"'{root}' (pass --root REPO_ROOT)") + return root + + +class CodeScanner: + """A whole-file character scanner that masks comments and literals. + + Produces per-line "code" text (same column positions; masked chars + become spaces). State (block comments, raw strings) carries across + lines. Handles: // line comments, /* */ block comments, string and + char literals (escape-aware), and raw strings R"delim(...)delim" + (delimiter ≤ 16 chars — [lex.string]). + """ + + def __init__(self, text): + self.text = text + self.n = len(text) + self.i = 0 + self.in_block = False + self.in_raw = False + self.raw_term = "" + self.line = 1 + self.out_lines = [] + self.out = [] + + def mask(self, count): + for _ in range(count): + if self.i < self.n and self.text[self.i] != "\n": + self.out.append(" ") + else: + self._nl() + self.i += 1 + + def _nl(self): + self.out_lines.append("".join(self.out)) + self.out = [] + self.line += 1 + + def scan(self): + while self.i < self.n: + c = self.text[self.i] + if self.in_raw: + if self.text.startswith(self.raw_term, self.i): + self.mask(len(self.raw_term)) + self.in_raw = False + else: + self.mask(1) + continue + if self.in_block: + if c == "*" and self.i + 1 < self.n and self.text[self.i + 1] == "/": + self.mask(2) + self.in_block = False + else: + self.mask(1) + continue + if c == "\n": + self._nl() + self.i += 1 + continue + if c == "/" and self.i + 1 < self.n: + if self.text[self.i + 1] == "/": + # Line comment: mask to end of line (the newline + # itself is handled by the loop). + while self.i < self.n and self.text[self.i] != "\n": + self.out.append(" ") + self.i += 1 + continue + if self.text[self.i + 1] == "*": + self.mask(2) + self.in_block = True + continue + if c == '"': + # Raw string? R"delim( — the preceding char must not be + # a name char (the R is a keyword, not part of an + # identifier). + is_kw = (self.i > 0 and self.text[self.i - 1] == "R" and + not (self.i >= 2 and + (self.text[self.i - 2].isalnum() or + self.text[self.i - 2] == "_"))) + if is_kw: + j = self.i + 1 + while j < self.n and self.text[j] != "(" and \ + j - self.i - 1 <= RAW_DELIM_MAX: + j += 1 + if j < self.n and self.text[j] == "(": + delim = self.text[self.i + 1:j] + self.raw_term = ")" + delim + '"' + self.mask(j - self.i + 1) + self.in_raw = True + continue + # Plain string literal (escape-aware). + j = self.i + 1 + while j < self.n and self.text[j] != "\n": + if self.text[j] == "\\": + j += 2 + continue + if self.text[j] == '"': + break + j += 1 + self.mask(min(j, self.n - 1) - self.i + 1) + continue + if c == "'": + j = self.i + 1 + while j < self.n and self.text[j] != "\n": + if self.text[j] == "\\": + j += 2 + continue + if self.text[j] == "'": + break + j += 1 + self.mask(min(j, self.n - 1) - self.i + 1) + continue + self.out.append(c) + self.i += 1 + self.out_lines.append("".join(self.out)) + return self.out_lines + + +def check_line(path, lineno, original, code): + """One line → (findings, suppressed). + + `original` is the raw line (the marker is detected in it — the + marker is a line comment); `code` is the masked line (comments and + literals replaced by spaces — the banned tokens are matched in it). + A valid same-line marker suppresses the line's findings; it counts + as a suppression only when it actually suppresses at least one + banned token (a marker on a clean line suppresses nothing). + """ + findings = [] + # D3 first: a half-written marker is a violation even when the + # line also carries a banned token. + marker_valid = False + if MARKER_ID in original: + m = MARKER.search(original) + if m and m.group(1).strip() != "": + marker_valid = True + else: + findings.append( + f"{path}:{lineno}: D3 malformed exception marker — " + f"the format is `// LAIGE-DETERM-EXCEPTION: G-R8 ` " + f"(rule id G-R8, non-empty reason, same line)") + return findings, False + has_banned = (TYPE_TOKEN.search(code) is not None or + FLOAT_LITERAL.search(code) is not None or + DOUBLE_LITERAL.search(code) is not None or + UNORDERED.search(code) is not None) + if marker_valid: + if has_banned: + # The documented off-path use: suppress the findings. + return findings, True + # A marker on a clean line suppresses nothing: not counted. + return findings, False + for m in TYPE_TOKEN.finditer(code): + findings.append(f"{path}:{lineno}: D1a raw `{m.group(1)}` type token " + f"(G-R8: sim translation units use SimMath only — " + f"ADR 0002; add a // LAIGE-DETERM-EXCEPTION: G-R8 " + f" marker only for a documented off-path " + f"use, docs/concepts/determinism.md)") + for m in FLOAT_LITERAL.finditer(code): + findings.append(f"{path}:{lineno}: D1b raw float literal " + f"`{m.group(0)}` (G-R8 — ADR 0002)") + for m in DOUBLE_LITERAL.finditer(code): + findings.append(f"{path}:{lineno}: D1c raw double literal " + f"`{m.group(0)}` (G-R8 — ADR 0003/0002)") + for m in UNORDERED.finditer(code): + findings.append(f"{path}:{lineno}: D2 unordered container " + f"`unordered_{m.group(1)}` (PRD §10.3: no unordered " + f"containers in sim hot paths)") + return findings, False + + +def scan(root): + findings = [] + files = 0 + suppressed = [] + sim_dir = root / "src" / "laige-sim" + for path in sorted(sim_dir.rglob("*")): + if not path.is_file() or path.suffix not in SOURCE_EXTS: + continue + files += 1 + rel = path.relative_to(root) + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as e: + fail(f"cannot read {rel}: {e}") + code_lines = CodeScanner(text).scan() + original_lines = text.splitlines() + for lineno, (original, code) in enumerate( + zip(original_lines, code_lines), 1): + f, sup = check_line(rel, lineno, original, code) + findings.extend(f) + if sup: + suppressed.append(f"{rel}:{lineno}") + return findings, files, suppressed + + +def main(argv): + root_arg = None + args = argv[1:] + if "--root" in args: + i = args.index("--root") + if i + 1 >= len(args): + print("usage: laige-determinism-lint [--root REPO_ROOT]", + file=sys.stderr) + return 2 + root_arg = args[i + 1] + try: + root = find_root(root_arg) + findings, files, suppressed = scan(root) + except Failure as e: + print(f"laige-determinism-lint: FAIL (structural): {e}", + file=sys.stderr) + return 2 + + if suppressed: + print(f"laige-determinism-lint: {len(suppressed)} suppressed line(s) " + f"carry a LAIGE-DETERM-EXCEPTION marker (EXC-006: review " + f"these in every change):") + for s in suppressed: + print(f" {s}") + if findings: + for v in findings: + print(f"laige-determinism-lint: FAIL\n {v}", file=sys.stderr) + print(f"laige-determinism-lint: {len(findings)} violation(s) " + f"across {files} scanned file(s)", file=sys.stderr) + return 1 + print(f"laige-determinism-lint: OK — {files} sim source file(s) " + f"scanned, 0 violations, {len(suppressed)} marked exception " + f"line(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From a59cb58595b2fe762040f7cc928f64268bac3ef1 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Tue, 15 Sep 2026 16:42:29 +0200 Subject: [PATCH 2/2] [M1-DET-01] Record change-log commit/PR reference The M1-DET-01 change-log line now carries 56f2835 / PR #34 (the reference was pending until this step landed as a pull request). --- roadmap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/README.md b/roadmap/README.md index b9d9622..e6643be 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -208,7 +208,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-LOOP-01 | `30f3013` | Fixed-timestep game loop core (FR-1.1, ARCH-002, PRD §10.2/§10.3; M1-LOOP-01 scope, nothing else): new `GameLoop` (public header `src/laige-sim/include/laige/sim/game_loop.h`, implementation `src/laige-sim/game_loop.cpp`) — the accumulator loop that advances the simulation in INTEGER ticks, decoupled from the presentation frame cadence: `GameLoop::create(world, schedule, options)` validates the typed config (first failure wins; every rejection = `InvalidArgument` + one rate-limited warn, FR-12.3/CORE-008 — `loop/tick_rate_invalid` for `tickRateHz` outside 20–120 (`kMinTickRateHz`/`kDefaultTickRateHz` = 60 / `kMaxTickRateHz`), `loop/catchup_invalid` for `maxCatchUpTicks == 0` (default `kDefaultMaxCatchUpTicks` = 5 — bounds per-frame work, not rate)) and holds non-owning world/schedule views (both outlive the loop; one live loop per world); `frame()` is the hot path (one clock read, a few integer ops, up to `maxCatchUpTicks` BOUNDED `runSystems` dispatches — PERF-002; no allocation, no logging on success — PERF-003/LOG-003) and runs exactly `min(due − ticksRun, maxCatchUpTicks)` ticks where `due(now) = floor(elapsedNs × rate / 10⁹)` is computed in EXACT integer arithmetic (the seconds/sub-seconds split keeps every product overflow-free; no floating point, no rounding drift — ARCH-010) and the unrun remainder is re-derived from the clock every frame (no stored accumulator state: a synthetic 10 s clock at 60 Hz yields EXACTLY 600 ticks — a float ms accumulator floors to 599); the first frame establishes the start reference (zero ticks); `beginFrame()` is driven once per FRAME (the entity.h contract: the G-R3/G-R4 per-frame windows are per presentation frame — a catch-up frame of N ticks counts against one per-frame budget, the documented overload signal) and `runSystems` once per tick; overload: when `want > maxCatchUpTicks` the frame runs exactly `maxCatchUpTicks` and DROPS exactly `want − maxCatchUpTicks` (counted in `droppedTicks`/`droppedFrames` — never silent) with one rate-limited `loop/tick_dropped` warn (NFR-13.3 5-field build-stable message; structured fields `dropped`/`total_dropped`/`max_catch_up`/`tick_rate_hz`; one event per rate window + the `rate_limited` summary at shutdown — LOG-004), and the per-frame work stays bounded so the accumulator never grows unboundedly (PERF-008 backpressure); failure: a stale/malformed schedule surfaces the `runSystems` `InvalidArgument` (`system/schedule_stale`/`schedule_invalid` — the loop adds no event), a failed tick is not counted (its system phase did not complete; no system runs in a failed frame — validation precedes dispatch), the tick count freezes and each later frame fails the same way (rate-limited) until the caller recreates the loop with a recomputed schedule; a moved-from loop is STOPPED (`frame()` → `InvalidArgument`, no log, no world access — the moved-from-world pure-failure precedent) while move transfers the tick state (the factory's `Result` move); the clock source is `Options::nowNs` (nanoseconds on a monotonic epoch time base; `nullptr` → the headless monotonic `steady_clock` — the LoggerOptions::ClockFn precedent; a backward reading below the start reference asserts in debug / clamps in release — never UB); `GameLoopStats` (frames/ticks/droppedTicks/droppedFrames) is the since-construction profiler feed (pure O(1) query — the `World::stats()` precedent; the M1-PROF-01 feed). Determinism scope (ARCH-009/010): the tick sequence is a pure function of (clock readings, rate, cap) — integer-only, bit-identical across builds for the same clock sequence (replay state — M1-DET-01/02 include the tick counter in the hash); clock readings are wall-clock facts (the windowed clock M2-GL-02 / replay runner M1-DET-03 supply the canonical time base); frames/drops are presentation/diagnostic state, never authoritative. New `GameLoop` suite (11 tests) + CTest entry `game_loop` (the step's Verify command; TSan property list): config validation + warns + read-back (the 121 Hz repeat is rate-limited and summarized at shutdown — `suppressed = 1`), the first frame runs zero ticks, exact 600 ticks over a synthetic 10 s clock (400 steps of 16666667 ns + 200 of 16666666 ns = 10¹⁰ ns; machine-greppable `game-loop exact` line), the overload drops EXACTLY 8/16/24 (48 total) over three 10-tick demands against a cap of 2 and logs once per episode (the NFR-13.3 grammar check + the `rate_limited` summary `suppressed = 2`; machine-greppable `game-loop drops` line), the healthy cadence runs 120 ticks / zero drops / silent with one `runSystems` dispatch per tick (the M1-SYS-03 feed tracks the ticks exactly), a stale schedule freezes the tick count and surfaces the `Status` (the `system/schedule_stale` warn rate-limited), a backward clock jump (release clamps to the start reference — no tick, no new event; debug asserts — forked SIGABRT child, POSIX jobs), the default `steady_clock` drives real frames (50 ms sleep → ≥ 3 ticks at 60 Hz), move transfers the state and stops the source (the stopped loop's `frame()` → `InvalidArgument`, no log, world untouched), and the zero-allocation window (300 frames × 2 ticks = 600 ticks, zero drops → `allocs = 0` — the test-only operator-new counter, non-sanitizer trees; machine-greppable `game-loop-zeroalloc` line; the sanitizer trees prove it leak-free). Verified: `ctest -R game_loop` green + full suite 44/44 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (30 source files, 1/10 vendored deps), `laige-api.json` regenerated (505 → 530 symbols; +25: `GameLoop` + members, `Options` + 3 fields, `GameLoopStats` + 4 fields, `kMinTickRateHz`/`kDefaultTickRateHz`/`kMaxTickRateHz`/`kDefaultMaxCatchUpTicks`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/game_loop.md` (the two cadences, the exact due computation, config + validation, the overload behavior, the beginFrame wiring, the failure behavior, the determinism scope, the profiler feed, the Performance section, misuse warnings) + cross-refs in `docs/api/system_timing.md`, `include/laige/sim/system.h` (the scheduler sketch now references `GameLoop`), `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. | 2026-09-14 | M1-LOOP-02 | `7d4cc0d` | Per-tick presentation snapshot + interpolation state (FR-1.1 render interpolation, the 2D-aware half; ARCH-009; PRD §4; M1-LOOP-02 scope, nothing else): `Position2D` — the FIRST built-in component (the entity's 2D simulation-space position as the selected SimMath backend's `Vec2`, ADR 0002; both backends registered: `Position2DFpx16` (fpx16_16, default) / `Position2DFp32` (fp32_pinned, opt-in)) + `PresentationSnapshot` (new header-only public header `src/laige-sim/include/laige/sim/presentation.h` — a class template, one instantiation per backend, the M1-ECS-02 pattern; no new .cpp): the per-completed-tick `prev`/`curr` capture over a pre-reserved per-slot `SlotRecord` table (24 B/slot; one setup-path allocation sized to `world.capacity()`, no per-tick/per-frame heap — PERF-003); NEW entities snap to `curr` (the documented scope behavior: an entity created before the first tick or added between ticks has no end-of-tick T−1 state, so it renders at its spawn position — no phantom interpolation — and interpolates normally from the second tick after creation; the record's stored generation is checked on every refresh, so a slot recycle self-heals — the 2^16 wrap carries the entity-handles' accepted caveat); the snapshot NEVER mutates the world (ARCH-009 — `prev`/`curr` are pure copies of authoritative state); the alpha `alpha = (R − A(T)) × rate / 10⁹` (tick anchor `A(T) = startNs + T × 10⁹/rate` on the loop's time base) is computed in EXACT integer arithmetic (the seconds/remainder split keeps every product overflow-free for any 64-bit clock reading — the `ticksDue` precedent; no float accumulator — ARCH-010) and is CLAMPED to [0, 1] — never extrapolates: before the anchor → 0, a clock jump a full tick or more past the anchor → 1, exact values in between preserved (the sub-second remainder contributes at most `rate − 1` full due ticks, so the branch bounds are overflow-free by construction); it is stored as the backend scalar with one documented rounding per backend (`detail::AlphaConversion`: Fp32Pinned one binary32 division; Fpx16_16 one round-to-nearest into Q16.16 raw) and is a WALL-CLOCK fact — non-deterministic by design, never part of replay state or the simulation state hash (M1-DET-03); `sample_position(e)` (the roadmap's exact name): `lerp(prev, curr, alpha)` (the SimMath backend's lerp, ADR 0002) for a synced entity, the CURRENT value for an entity first seen since the last refresh (snaps), `InvalidArgument` + warn-once `ecs/stale_entity_access` for a stale/invalid handle (the `World::check` precedent — never silent, FR-12.3), `InvalidArgument` with NO warn for a live handle without a `Position2D` (a negative query, like `has()` reading false), and `InvalidArgument` with no world access / no log for a moved-from snapshot (the `GameLoop` moved-out precedent); `create(world, startReferenceNs, options)` validates `tickRateHz` against the loop's documented 20–120 Hz range (first failure wins — `InvalidArgument` + one rate-limited warn `presentation/tick_rate_invalid`, field `tick_rate_hz`; equality with the driven loop's rate is the engine's wiring guarantee, the preamble's misuse warnings); move-only (an O(1) pointer swap; the moved-from snapshot is STOPPED — every operation fails with `InvalidArgument`, no world access, no logging); the `GameLoop` gains the M1-HEAD-01 wiring seam: `Options::onTick` (a plain `noexcept` function pointer — no std::function, PERF-006 — fired ONCE per COMPLETED tick after the tick's system phase as `onTick(context, world, tick)`, with a failed tick neither counted nor hook-fired) + `onTickContext` + `startReferenceNs()` (the loop's first-frame clock reading — the alpha's anchor base); docs: NEW `docs/api/presentation.md` (full contract + the DOC-004 Performance section), `docs/api/game_loop.md` (the hook preamble section, the Options table row, `startReferenceNs`, the per-tick Performance note), `docs/README.md` (API index + M1 status line), the module README; tests: `tests/laige-sim/presentation_tests.cpp` (suite `Presentation`; CTest entry `presentation` = the step's Verify command), 10 cases — `create` tick-rate validation + the warn shape (memory sink), LINEAR interpolation at exact Q16.16 raw values (alpha 0/0.5/0.75 and the near-1 rounding — raw-unit expectations, no float round-trips; machine-greppable `presentation linear` line), the ALPHA CLAMP matrix (before the anchor / 1 ns either side / a small 0.001 alpha / exactly the next anchor / a half-tick clock jump / 5 s and 16.7 min jumps / a 285-year reading / a below-start reading), ENTITY-ADDED-BETWEEN-TICKS snaps to curr then interpolates normally, CATCH-UP per-tick refresh (one frame, two ticks — the sample uses the LATEST tick's interval), STALE handle rejection (warn-once) + missing-component rejection (no warn), the GAMELOOP HOOK integration (a movement system over `Io` wired through the thunk; `snap.lastTick() == loop.currentTick()` at every frame; a catch-up frame refreshes per tick; a failed tick (stale schedule) does not fire the hook), MOVED snapshot stops the source (no world access, no log; move-assignment transfers), the ZERO-ALLOC window (500 entities × 100 frames of position updates + `onTick` + `onRenderFrame` + 100 `sample_position` calls; test-only operator-new counter, machine-greppable `presentation-zeroalloc ... allocs=0`, non-sanitizer trees; the sanitizer trees prove the same window leak-free), and the FP32 BACKEND instantiating the same contract (exact 0.5f midpoint lerp); `laige-api.json` regenerated (555 symbols — +24 public symbols: `Position2D`/`Position2DFpx16`/`Position2DFp32`, `PresentationSnapshot` + members, `GameLoop::Options::TickFn`/`onTick`/`onTickContext`, `GameLoop::startReferenceNs`; `api-real-tree` green); local Verify: `ctest -R presentation` green, the canonical g++ tree zero-warning with full `ctest` 45/45, and zero-warning 45/45 on `build-asan`, `build-tsan`, `build-clang`, `build-release`, `build-shared`; `tools/laige-include-lint` OK; Progress Board 12/25 (total 32/193) | | 2026-09-15 | M1-HEAD-01 | `e80ccc8` / PR #31 | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | -| 2026-09-15 | M1-DET-01 | — (working tree; commit pending) | Deterministic mode + sim math rules (FR-1.4, S-7, PRD §10.3; ARCH-010; M1-DET-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/determinism.h` — `SimMathBackend` (`FixedPoint16_16` default / `FloatPinned32`), `DeterminismConfig {enabled, math}`, the G-R8 compile-time trait (`detail::IsDeterminismSafe`: false by default; true for integers, enums, `fpx16_16`, `float` (the fp32_pinned Scalar), the four `SimMath::Vec2/Vec3`; `double` intentionally never safe — no backend uses it), `detail::areDeterminismSafeMembers` (the &&-fold), and `LAIGE_DETERMINISM_SAFE(Type, Members...)` (declares the member list IS the storage; a non-safe member — e.g. `double` — is a compile error AT THE MARK SITE, a new `static_assert` in the specialization, before any system can use the component); the trait is enforced by a third `static_assert` in `World::registerSystem` (entity.h) folding `detail::IoComponentSafety>` over the declared I/O, with an actionable message naming the fix and pointing at the docs; PRNG substreams wired: `World::Options` gains `seed`/`deterministic` (world state carried through create/move/assign; entity.cpp), `registerSystem` derives each system's substream `Prng::deriveSubstream(seed, systemId)` (id 0 = master, never assigned) into `detail::SystemRecord.rng` (`std::optional`), and `runSystems` hands the NON-const record's stream to `SystemContext.rng` (a new `Prng*` field, NSDMI — advanced in place during draws: the stream state IS the replay state); `EngineConfig` appends `seed` (full u64, `kDefaultSimulationSeed = 0`) + `DeterminismConfig determinism` (existing 3-member aggregate inits keep compiling); `parseEngineConfig` gains `seed` (0..2^53 — the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted as 2^53, 2^53+2 is the smallest rejectable value) + the `determinism` object (`enabled` bool, `math` ∈ the two ids; unknown nested key → one `config/unknown_key` warn, ignored — first failure wins across keys) with new rejection events `config/seed_invalid` / `config/determinism_invalid` / `config/determinism_enabled_invalid` / `config/determinism_math_invalid`; `Engine::create` forwards seed + mode to the world and registers the backend-matching built-in FIRST (`Position2DFpx16` / `Position2DFp32`) and builds the presentation snapshot for the same backend (type-erased `detail::PresentationHandle` — one setup allocation, the fnptr-deleter `unique_ptr` idiom, zero added allocations: the headless setup path stays exactly 3); `engine/run_started` gains `seed`/`determinism`/`math` fields (the `laige-run` CLI summary line is unchanged); `tools/laige-determinism-lint` (NEW; Python 3 stdlib, the `laige-include-lint` style) — the sim-source scan over `src/laige-sim/**`: D1a raw `float`/`double` type tokens, D1b float literals, D1c double literals, D2 `unordered_{map,set,multimap,multiset}`, D3 malformed exception markers; a char scanner strips `//`/`/* */` comments, string/char literals, and raw strings before matching (case-sensitive, word-bounded: `Float`/`fromFloat`/`next_float01` do not match); the documented false-positive policy = same-line `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers (15 legitimate in-tree: the M1-SYS-03 wall-clock diagnostics, the presentation alpha conversion, the ADR 0003 JSON number policy, the trait's own `float` registration); every suppressed line is counted + printed (EXC-006: exceptions stay visible in every CI run); exit 0/1/2. Tests: `tests/laige-sim/determinism_tests.cpp` (suites `DeterminismMode`/`DeterminismEngine`/`DeterminismConfigParse`; CTest `determinism_mode` = the step's Verify command) — a trivial moving-entity sim (two entities, `Position2DFpx16` + a marked `DetVel` component, a mover system doing one `ctx.rng->next_range(0,5)` draw per tick at a fixed position + `pos += vel` through SimMathFpx16 ops only) produces BIT-IDENTICAL FNV-1a per-tick state hashes (tick + handle words + raw component words, each<> order) over 256 ticks in two consecutive runs (machine-greppable `determinism-tick-stream` line); a different seed diverges; the system's draws equal an independently constructed `Prng::deriveSubstream(seed, id)` exactly (golden cross-check) and two systems' streams are independent; `deterministic == false` → `ctx.rng == nullptr`; backend selection (fp32 config → `Position2DFp32` duplicate-rejected / `Position2DFpx16` available + 30-tick run completes; default → the inverse); the config keys (defaults, valid values, the rejection table incl. the 2^53 bound exactness, unknown-nested-key forward-compat, first-failure-wins). `tests/laige-sim/compile_fail/` (4 fixtures + `expect-compile-result.cmake.in`, CTest `trait_compile_*`): the positive fixture compiles (exit 0); the three negatives (a `double` member, an unmarked user struct, a `double` in the mark's member list) each FAIL to compile with the G-R8 message (exit-code + stderr-fragment assertions — an incidental compiler error cannot masquerade as the trait). `tests/tools` gains the `determinism-lint-*` fixture tests (clean tree with one marked exception → exit 0; one violation per rule → exit 1; real tree → exit 0) reusing the include-lint pattern; CI: `determinism-lint` job added to BOTH `.github/workflows/ci-pull.yml` and `ci.yml` (ubuntu-24.04, `python3 tools/laige-determinism-lint`). Docs (DOC-007, same change): NEW `docs/concepts/determinism.md` (the ARCH-010 scope statement — what is deterministic, at what scope, verified how, what it is not; the two-layer G-R8 enforcement; the exception policy; the PRNG substreams; the mode table; the provisional config surface) + NEW `docs/api/determinism.md` (the trait API contract) + updates to `docs/api/engine.md` (the seed/determinism keys, backend selection, run_started fields, the determinism scope), `docs/api/system_registry.md` (the `ctx.rng` bullet + the G-R8 validation row), `docs/api/entity.md` (the `World::Options` seed/deterministic fields), `docs/api/sim_math.md` (the G-R8 enforcement note), `docs/testing.md` (the determinism test entries), `docs/concepts/README.md`, `docs/README.md`, `src/laige-sim/README.md`. `laige-api.json` regenerated (573 → 588 symbols; +15: `SimMathBackend` + 2, `DeterminismConfig` + 2, `LAIGE_DETERMINISM_SAFE`, `World::Options` + 2, `SystemContext::rng`, `EngineConfig` + 2, `kDefaultSimulationSeed`; `api-real-tree` green). Verified: `ctest -R determinism_mode` green (14/14), `ctest -R trait_compile` green (4/4), `ctest -R determinism-lint` green (3/3), `python3 tools/laige-include-lint` OK, `python3 tools/laige-determinism-lint` OK (17 files, 15 marked exceptions), full `ctest` 55/55 on `build` (Debug GCC 16.2.1) and 55/55 on `build-asan` (ASan+UBSan leak-free); zero new warnings under NFR-8.10. **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the `seed`/`determinism` keys sit on the PROVISIONAL `parseEngineConfig` surface (documented as provisional in engine.md, the header preamble, and this log line); M1-CFG-01 owns the final versioned schema. | +| 2026-09-15 | M1-DET-01 | `56f2835` / PR #34 | Deterministic mode + sim math rules (FR-1.4, S-7, PRD §10.3; ARCH-010; M1-DET-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/determinism.h` — `SimMathBackend` (`FixedPoint16_16` default / `FloatPinned32`), `DeterminismConfig {enabled, math}`, the G-R8 compile-time trait (`detail::IsDeterminismSafe`: false by default; true for integers, enums, `fpx16_16`, `float` (the fp32_pinned Scalar), the four `SimMath::Vec2/Vec3`; `double` intentionally never safe — no backend uses it), `detail::areDeterminismSafeMembers` (the &&-fold), and `LAIGE_DETERMINISM_SAFE(Type, Members...)` (declares the member list IS the storage; a non-safe member — e.g. `double` — is a compile error AT THE MARK SITE, a new `static_assert` in the specialization, before any system can use the component); the trait is enforced by a third `static_assert` in `World::registerSystem` (entity.h) folding `detail::IoComponentSafety>` over the declared I/O, with an actionable message naming the fix and pointing at the docs; PRNG substreams wired: `World::Options` gains `seed`/`deterministic` (world state carried through create/move/assign; entity.cpp), `registerSystem` derives each system's substream `Prng::deriveSubstream(seed, systemId)` (id 0 = master, never assigned) into `detail::SystemRecord.rng` (`std::optional`), and `runSystems` hands the NON-const record's stream to `SystemContext.rng` (a new `Prng*` field, NSDMI — advanced in place during draws: the stream state IS the replay state); `EngineConfig` appends `seed` (full u64, `kDefaultSimulationSeed = 0`) + `DeterminismConfig determinism` (existing 3-member aggregate inits keep compiling); `parseEngineConfig` gains `seed` (0..2^53 — the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted as 2^53, 2^53+2 is the smallest rejectable value) + the `determinism` object (`enabled` bool, `math` ∈ the two ids; unknown nested key → one `config/unknown_key` warn, ignored — first failure wins across keys) with new rejection events `config/seed_invalid` / `config/determinism_invalid` / `config/determinism_enabled_invalid` / `config/determinism_math_invalid`; `Engine::create` forwards seed + mode to the world and registers the backend-matching built-in FIRST (`Position2DFpx16` / `Position2DFp32`) and builds the presentation snapshot for the same backend (type-erased `detail::PresentationHandle` — one setup allocation, the fnptr-deleter `unique_ptr` idiom, zero added allocations: the headless setup path stays exactly 3); `engine/run_started` gains `seed`/`determinism`/`math` fields (the `laige-run` CLI summary line is unchanged); `tools/laige-determinism-lint` (NEW; Python 3 stdlib, the `laige-include-lint` style) — the sim-source scan over `src/laige-sim/**`: D1a raw `float`/`double` type tokens, D1b float literals, D1c double literals, D2 `unordered_{map,set,multimap,multiset}`, D3 malformed exception markers; a char scanner strips `//`/`/* */` comments, string/char literals, and raw strings before matching (case-sensitive, word-bounded: `Float`/`fromFloat`/`next_float01` do not match); the documented false-positive policy = same-line `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers (15 legitimate in-tree: the M1-SYS-03 wall-clock diagnostics, the presentation alpha conversion, the ADR 0003 JSON number policy, the trait's own `float` registration); every suppressed line is counted + printed (EXC-006: exceptions stay visible in every CI run); exit 0/1/2. Tests: `tests/laige-sim/determinism_tests.cpp` (suites `DeterminismMode`/`DeterminismEngine`/`DeterminismConfigParse`; CTest `determinism_mode` = the step's Verify command) — a trivial moving-entity sim (two entities, `Position2DFpx16` + a marked `DetVel` component, a mover system doing one `ctx.rng->next_range(0,5)` draw per tick at a fixed position + `pos += vel` through SimMathFpx16 ops only) produces BIT-IDENTICAL FNV-1a per-tick state hashes (tick + handle words + raw component words, each<> order) over 256 ticks in two consecutive runs (machine-greppable `determinism-tick-stream` line); a different seed diverges; the system's draws equal an independently constructed `Prng::deriveSubstream(seed, id)` exactly (golden cross-check) and two systems' streams are independent; `deterministic == false` → `ctx.rng == nullptr`; backend selection (fp32 config → `Position2DFp32` duplicate-rejected / `Position2DFpx16` available + 30-tick run completes; default → the inverse); the config keys (defaults, valid values, the rejection table incl. the 2^53 bound exactness, unknown-nested-key forward-compat, first-failure-wins). `tests/laige-sim/compile_fail/` (4 fixtures + `expect-compile-result.cmake.in`, CTest `trait_compile_*`): the positive fixture compiles (exit 0); the three negatives (a `double` member, an unmarked user struct, a `double` in the mark's member list) each FAIL to compile with the G-R8 message (exit-code + stderr-fragment assertions — an incidental compiler error cannot masquerade as the trait). `tests/tools` gains the `determinism-lint-*` fixture tests (clean tree with one marked exception → exit 0; one violation per rule → exit 1; real tree → exit 0) reusing the include-lint pattern; CI: `determinism-lint` job added to BOTH `.github/workflows/ci-pull.yml` and `ci.yml` (ubuntu-24.04, `python3 tools/laige-determinism-lint`). Docs (DOC-007, same change): NEW `docs/concepts/determinism.md` (the ARCH-010 scope statement — what is deterministic, at what scope, verified how, what it is not; the two-layer G-R8 enforcement; the exception policy; the PRNG substreams; the mode table; the provisional config surface) + NEW `docs/api/determinism.md` (the trait API contract) + updates to `docs/api/engine.md` (the seed/determinism keys, backend selection, run_started fields, the determinism scope), `docs/api/system_registry.md` (the `ctx.rng` bullet + the G-R8 validation row), `docs/api/entity.md` (the `World::Options` seed/deterministic fields), `docs/api/sim_math.md` (the G-R8 enforcement note), `docs/testing.md` (the determinism test entries), `docs/concepts/README.md`, `docs/README.md`, `src/laige-sim/README.md`. `laige-api.json` regenerated (573 → 588 symbols; +15: `SimMathBackend` + 2, `DeterminismConfig` + 2, `LAIGE_DETERMINISM_SAFE`, `World::Options` + 2, `SystemContext::rng`, `EngineConfig` + 2, `kDefaultSimulationSeed`; `api-real-tree` green). Verified: `ctest -R determinism_mode` green (14/14), `ctest -R trait_compile` green (4/4), `ctest -R determinism-lint` green (3/3), `python3 tools/laige-include-lint` OK, `python3 tools/laige-determinism-lint` OK (17 files, 15 marked exceptions), full `ctest` 55/55 on `build` (Debug GCC 16.2.1) and 55/55 on `build-asan` (ASan+UBSan leak-free); zero new warnings under NFR-8.10. **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the `seed`/`determinism` keys sit on the PROVISIONAL `parseEngineConfig` surface (documented as provisional in engine.md, the header preamble, and this log line); M1-CFG-01 owns the final versioned schema. | ---