Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci-pull.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,35 @@ jobs:
run: cmake --build build --target laige-api-scanner -j
- name: Check manifest drift
run: ./build/bin/laige-api-scanner --root . --check laige-api.json

detcheck:
# M0-TOOL-02: runs on every PR (no ci:* condition). The determinism
# checker skeleton (FR-11.5): the built-in synthetic scenario
# self-check runs here so a broken checker lands red before the real
# scenarios exist. The real-scenario comparison (two build
# configurations of M1-SAMPLE-01's hello) is skipped until that
# sample lands; M1-DET-04 activates it (see the job in ci.yml for
# the full note).
name: Determinism check
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build -j
- name: Synthetic scenario self-check
run: ./build/bin/laige-detcheck --scenario=synthetic
- name: Real scenario (M1-SAMPLE-01)
# Skipped until M1-SAMPLE-01 lands the hello scenario binary;
# M1-DET-04 replaces this step with the two-configuration
# comparison (see the job in ci.yml).
run: |
if [ -x samples/hello/bin/hello ]; then
./build/bin/laige-detcheck \
--run-a=samples/hello/bin/hello \
--run-b=samples/hello/bin/hello
else
echo "skipped: no real scenario yet (M1-SAMPLE-01)"
fi
48 changes: 45 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
# Cadence (PRD §14: "one per PR, all per merge"):
# * this workflow runs on every push to the default branch (master) —
# i.e. on merge — and on manual workflow_dispatch:
# ALL eight jobs run (the five P0 OS/variant jobs, the two sanitizer
# lanes (M0-CI-02), and the platform-independent include-graph lint
# with dependency-count metric, M0-CI-03).
# 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)).
# * Pull requests run exactly ONE P0 OS (label-selectable, default
# Linux) in the companion workflow .github/workflows/ci-pull.yml.
#
Expand All @@ -26,6 +28,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)
# detcheck Determinism check (M0-TOOL-02)
#
# The include-lint job (M0-CI-03; NFR-8.11, NFR-8.13) is platform-
# independent — it parses the #include edges of src/** (PRD §10.1 rules:
Expand Down Expand Up @@ -269,3 +272,42 @@ jobs:
run: cmake --build build --target laige-api-scanner -j
- name: Check manifest drift
run: ./build/bin/laige-api-scanner --root . --check laige-api.json

detcheck:
# M0-TOOL-02 (FR-11.5; AGENTS ARCH-010, TEST-004): the determinism
# checker skeleton. This job runs the built-in synthetic scenario
# self-check (two in-process runs, per-tick hash comparison — the
# step's Verify clause), so a broken checker lands red here before
# the real scenarios exist. The real-scenario comparison — two build
# configurations of M1-SAMPLE-01's hello scenario (Debug+ASan vs
# Release, plus g++ vs clang++ on Linux) — is SKIPPED until that
# sample lands; M1-DET-04 activates it and records the result per
# ARCH-010. Like include-lint and api-manifest, the job runs on every
# PR and merge, independent of the ci:* label selector — it is a
# tooling check, not an additional P0 OS build (the PRD §14 cadence
# is unchanged).
name: Determinism check
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build -j
- name: Synthetic scenario self-check
run: ./build/bin/laige-detcheck --scenario=synthetic
- name: Real scenario (M1-SAMPLE-01)
# Skipped until M1-SAMPLE-01 lands the hello scenario binary;
# M1-DET-04 replaces this step with the two-configuration
# comparison, e.g.:
# ./build/bin/laige-detcheck --run-a=build-asan/bin/hello
# --run-b=build/bin/hello
run: |
if [ -x samples/hello/bin/hello ]; then
./build/bin/laige-detcheck \
--run-a=samples/hello/bin/hello \
--run-b=samples/hello/bin/hello
else
echo "skipped: no real scenario yet (M1-SAMPLE-01)"
fi
7 changes: 4 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ if(LAIGE_BUILD_TESTS)
add_subdirectory(tests)
# Dev tools that need the built engine library (gated with tests: a
# library-only build does not need them).
add_subdirectory(tools/fuzz)
add_subdirectory(tools/bench) # M0-CORE-08: laige-bench
add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner
add_subdirectory(tools/fuzz) # M0-CORE-07: laige-fuzz
add_subdirectory(tools/bench) # M0-CORE-08: laige-bench
add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner
add_subdirectory(tools/detcheck) # M0-TOOL-02: laige-detcheck
endif()
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The AI-relevant hardware the model runs on:
| `deps/` | Vendored dependencies, tracked by `deps.lock` (PRD §11) — lands in M0-DEP-01 |
| `third_party/` | Reserved placeholder for vendored code outside `deps.lock` |
| `tests/` | Unit/integration tests, mirroring the `src/` module layout |
| `tools/` | Engine tools and CI scripts (fuzz runner, API manifest, lints) |
| `tools/` | Engine tools and CI scripts (fuzz runner, API manifest, determinism checker, lints) |
| `samples/` | Reference game projects (flagship isometric ARPG, platformer, lockstep arena, MMO demo zone) |
| `docs/` | Documentation; [decision index](docs/decisions/README.md) (full structure lands in M0-DOC-01) |

Expand Down
8 changes: 5 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ sections below mark what exists and what is still to land.
- [Building Laige](getting-started/building.md) — the source of truth for
the canonical build commands, build trees, options, compiler policy
(NFR-8.10), sanitizer builds (NFR-8.2), and the current M0 status.
Tool commands are reserved there: `laige-fuzz`, `laige-bench`,
`laige-detcheck`, the `laige-api` manifest target, and the
include-graph lint.
Tool commands: `laige-fuzz`, `laige-bench`, `laige-detcheck`, the
`laige-api` manifest target, and the include-graph lint.

## API contracts (per public header)

Expand All @@ -31,6 +30,9 @@ sections below mark what exists and what is still to land.
schema (M0-CORE-08).
- [PRNG](api/prng.md) — `laige::Prng`: the splitmix64/LCG64 hybrid,
period, and determinism contract (M0-CORE-06).
- [Determinism checker](api/detcheck.md) — the `laige-detcheck` tool and
the scenario hash-line contract (`<tick> <hash>` lines, two build
configurations) (M0-TOOL-02).

## Architecture decisions (ADRs)

Expand Down
143 changes: 143 additions & 0 deletions docs/api/detcheck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Determinism checker (`laige-detcheck`) and the scenario contract

The M0 skeleton of the PRD FR-11.5 determinism checker (roadmap step
**M0-TOOL-02**; AGENTS ARCH-010, TEST-004). Implementation:
`tools/detcheck/laige-detcheck.cpp` (the normative scenario contract is
in its header comment, which this page mirrors); CTest suite:
`ctest -R detcheck` (`tests/detcheck`). Canonical command form:
[building.md](../getting-started/building.md).

`laige-detcheck` runs a named scenario in **two build configurations**
(e.g. Debug+ASan vs Release, or two compiler builds) and asserts that the
per-tick state hashes are identical — bit-identity of the deterministic
state across configurations (NFR-8.3, FR-1.4). The engine state-hash API
arrives with **M1-DET-03** (`world.state_hash`); this tool therefore works
against the **hash-file output contract** defined below and compares two
such streams.

## Scenario contract

A scenario is a deterministic program that simulates a fixed number of
ticks from a fixed seed and prints exactly one line per simulated tick,
in order, to **stdout**:

```text
<tick> <hash>
```

- **`<tick>`** — non-negative decimal integer, no padding or leading
zeros. The first line is tick `0`; each later tick is exactly one
higher (no gaps, no duplicates).
- **`<hash>`** — exactly **16 lowercase hex digits**, the canonical text
form of a 64-bit state hash (M1-DET-03's `world.state_hash`). The hash
*algorithm* is not part of the contract: `laige-detcheck` compares the
lines byte-for-byte, so only run-to-run identity matters.
- One line separator per line; a trailing newline on the final line is
optional, and an optional trailing `\r` is tolerated (Windows CRLF).
- **stderr** is ignored by the checker (it remains visible in the CI job
log).
- The scenario exits `0` on completion; any other exit code is a
scenario failure.

The checker enforces the contract strictly (a malformed scenario is a
loud exit-2 error, never a silent mismatch — CORE-008) and bounds the
output: at most **65536** ticks and 64 bytes per line.

## Command line

```text
laige-detcheck --scenario=<name> [--ticks=N] [--seed=HEX|DEC]
laige-detcheck --run-a=<scenario-bin-A> --run-b=<scenario-bin-B>
[-- scenario-args...]
```

**Mode 1 — `--scenario`** (M0 built-in scenarios, in-process):

| Name | Meaning |
|---|---|
| `synthetic` | 32 `fpx16_16` bodies + seeded `laige::Prng` input, run twice in two identical configurations — the self-check |
| `synthetic-perturbed` | the same, but run-b adds 1 unit to body 3's x at tick 7 — the perturbation fixture that proves the failure path |

`--ticks` (1..65536, default 256) and `--seed` (0xHEX or decimal, default
`0x1de7c0de`) apply to the built-in scenario only.

**Mode 2 — `--run-a`/`--run-b`** (the real mode, activated by
**M1-DET-04**): two builds of the same scenario source (two build
configurations) are executed and their hash streams compared. Everything
after the `--` separator is passed to both scenario binaries, so
scenario arguments can never collide with tool flags.

## Report and exit codes

stdout (stable and machine-greppable — LOG-001):

```text
detcheck scenario=synthetic result=OK ticks=256
run-a: synthetic[seed=0x1de7c0de ticks=256 build=Debug]
run-b: synthetic[seed=0x1de7c0de ticks=256 build=Debug]
```

```text
detcheck scenario=synthetic-perturbed result=DIVERGED first_diff_tick=7
run-a: 7 485959cdde7acb9c
run-b: 7 93a3363d5a1dffa9
```

In mode 2 the first line is
`detcheck scenario=<basename-a> vs <basename-b> result=... ticks=<n>` and
the `run-a`/`run-b` lines carry the full scenario paths. When one stream
ends early, the tick lines become stream-length notes
(`stream ends: <n> ticks`) with `result=DIVERGED`.

| Exit | Meaning |
|---|---|
| 0 | the two runs agree on every tick (deterministic) |
| 1 | divergence detected (a determinism failure — loud, CORE-008) |
| 2 | usage error, unknown scenario, a scenario run failed (non-zero exit, spawn failure), or a scenario violated the output contract (malformed line, tick gap, unbounded output) |

## The built-in synthetic workload

32 bodies of Q16.16 position/velocity (the default deterministic backend,
ADR 0002). Each tick: fixed-order integration (`x += vx`, `y += vy`),
wrap into a 64-unit box, then one seeded input event — the Prng picks the
body index and a nudge in [-4, 3] applied to x. Per-tick hash: FNV-1a 64
(the house constants, same as the `math_fixed` known-answer test) over
(tick, seed, every body's four raw words), big-endian per word
(endianness-independent).

**Determinism scope (ARCH-010):** pure unsigned-integer arithmetic
(`fpx16_16` ops + xorshift128+) — bit-exact across build, platform, ISA,
and compiler by the language standard; no float anywhere in the workload.
**Hash scope (M0):** tick counter + seed + body words. The Prng position
is a pure function of (seed, nudge history) in this workload; the
definitive scope — including PRNG state and the exact hash function — is
defined by M1-DET-03's `world.state_hash`, which replaces the ad-hoc FNV
computation of scenarios (the tool's line-by-line comparison is unchanged).

## Performance and bounds

A CI tool, not a hot path: one scenario run is O(ticks × 32); captured
streams are bounded (65536 lines × 64 bytes ≈ 1.5 MiB worst case per
run). Process execution is a plain pipe capture (fork/exec on POSIX,
`CreateProcessW` on Windows) — no shell, no temporary files, bounded
memory, and the scenario's stderr stays on the CI log.

## Test suite

`ctest -R detcheck` (`tests/detcheck`) — the tool tested with a synthetic
two-run scenario: the built-in self-check, the built-in perturbation
fixture, and the cross-binary mode against fixture scenario binaries
(one source, five compiled variants: clean / perturbed / bad output /
early exit / short stream). Each test is a generated `cmake -P` check
script asserting both the exit code and the required output fragments
(same pattern as `tests/api`).

## CI status

The `detcheck` CI job (`.github/workflows/ci.yml` and `ci-pull.yml`) runs
the built-in self-check on every PR and merge (like `include-lint` and
`api-manifest`, independent of the `ci:*` label selector — it is a
tooling check, not an additional P0 OS build). The real-scenario
comparison — two build configurations of M1-SAMPLE-01's `hello` — is
**skipped until that sample exists**; **M1-DET-04** activates it and
records the result per ARCH-010.
32 changes: 25 additions & 7 deletions docs/getting-started/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md)
Notes:

- `Debug` is the canonical `CMAKE_BUILD_TYPE`; `Release` is supported.
- The four rows above the lint row name tools that land in later M0 steps —
`laige-fuzz` (minimal form from M0-CORE-07: the `json_parse` target and
deterministic bounded runs; M0-TEST-01 extends it with CI lane semantics
and nightly long runs), `laige-bench` (M0-CORE-08), `laige-detcheck`
(M0-TOOL-02), target `laige-api` (M0-TOOL-01). Their command forms are
fixed here now so later steps cannot drift.
- The tool rows above the lint row are live targets now: `laige-fuzz`
(minimal form from M0-CORE-07: the `json_parse` target and deterministic
bounded runs; M0-TEST-01 extends it with CI lane semantics and nightly
long runs), `laige-bench` (M0-CORE-08), `laige-detcheck` (M0-TOOL-02),
and target `laige-api` (M0-TOOL-01). Their command forms were fixed here
when they were reserved, so no step can drift them.
- Include-graph lint (M0-CI-03): platform-independent (Python 3 stdlib
only, no setup). It parses the `#include` edges of `src/**` and enforces
the PRD §10.1 rules (laige-core includes nothing internal; arrows only
Expand Down Expand Up @@ -158,7 +158,8 @@ pinned set and the NaN/Inf policy):
and the fpx16_16 rounding/saturation policy in
[docs/api/sim_math.md](../api/sim_math.md), pinned flags via
`laige_apply_simmath_policy()`), and the memory pools from M0-CORE-05
(`include/laige/pools.h`: `laige::ArenaPool<T>` and `laige::Pool<T>` with `laige::PoolStats` accounting; API contract in [docs/api/pools.md](../api/pools.md)), and the bounded JSON parser + serializer from M0-CORE-07 (`include/laige/json.h`, `json.cpp`: `laige::JsonValue`, `parseJson`, `serializeJson`, `JsonOptions`; API contract in [docs/api/json.md](../api/json.md)).- `tests/laige-core/laige-core_tests` is a CTest link smoke test (a
(`include/laige/pools.h`: `laige::ArenaPool<T>` and `laige::Pool<T>` with `laige::PoolStats` accounting; API contract in [docs/api/pools.md](../api/pools.md)), and the bounded JSON parser + serializer from M0-CORE-07 (`include/laige/json.h`, `json.cpp`: `laige::JsonValue`, `parseJson`, `serializeJson`, `JsonOptions`; API contract in [docs/api/json.md](../api/json.md)).
- `tests/laige-core/laige-core_tests` is a CTest link smoke test (a
GoogleTest suite since M0-DEP-01) that runs in every build tree above: it
verifies the static/shared link and checks the NFR-8.10 policy flags with
`static_assert` (a policy violation fails the build).
Expand Down Expand Up @@ -208,6 +209,23 @@ pinned set and the NaN/Inf policy):
manifest and fails on any drift (PRD §9.4, NFR-13.1). The manifest
contract (symbol kinds, doc association, exit codes, unsupported
constructs) is the header comment of `tools/api/laige-api.cpp`.
- `detcheck-synthetic`, `detcheck-synthetic-perturbed`,
`detcheck-bin-identical`, `detcheck-bin-identical-args`,
`detcheck-bin-diverged`, `detcheck-bin-malformed`,
`detcheck-scenario-failure`, `detcheck-stream-mismatch`, and
`detcheck-unknown-scenario` are the M0-TOOL-02 CTest entries
(`tests/detcheck`): the determinism checker
(`tools/detcheck/laige-detcheck`) runs the synthetic two-run scenario —
the built-in `synthetic` self-check, the built-in perturbation fixture,
and the cross-binary mode against fixture scenario binaries (one source,
five compiled variants) — asserting both the exit code and the required
output fragments per test (`ctest -R detcheck`). The scenario contract
(`<tick> <hash>` lines, 16 lowercase hex hash digits) and the tool's
report/exit-code grammar are in
[docs/api/detcheck.md](../api/detcheck.md); the CI `detcheck` job runs
the self-check on every PR and merge, with the real-scenario comparison
(two build configurations) skipped until M1-SAMPLE-01 (M1-DET-04
activates it).
- Every configure verifies the vendored dependency lock
(`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or
unlisted file under `deps/` fails the configure loudly. GoogleTest is the
Expand Down
Loading
Loading