diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 384cfc2..5f83155 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -306,13 +306,19 @@ jobs: 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). + # M1-DET-04: runs on every PR (no ci:* condition). The determinism + # checker (FR-11.5): the built-in synthetic scenario self-check, + # then the M1-SAMPLE-01 hello scenario asserted against the + # committed per-tick hash baselines (samples/hello/baselines/) on + # BOTH SimMath backends (fixed_point_16_16, float_pinned_32 — ADR + # 0002) with `hello --expect` (the laige-replay --expect contract: + # 0 match, 1 first divergence, 2 baseline read/contract error). + # The PR's P0 OS job's ctest repeats the same baseline assertion on + # its own platform (tests/sample: hello_baseline_fpx / + # hello_baseline_fp32); this tooling job guarantees the check runs + # on every PR, including ones labelled for non-Linux P0 OSes. The + # full two-configuration matrix (g++ vs clang++, Debug+ASan vs + # Release) runs on merges in the detcheck job of ci.yml. name: Determinism check runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -324,15 +330,13 @@ jobs: 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). + - name: Real scenario vs baselines (M1-SAMPLE-01, both backends) + # The hello template is always built in this job, so both steps + # always execute. Each must exit 0 (a first-divergence report is + # exit 1; a baseline read/contract failure is exit 2) and print + # the 301-line per-tick hash stream. 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 + ./samples/hello/bin/hello \ + --expect samples/hello/baselines/fixed_point_16_16/hash_stream.txt + ./samples/hello/bin/hello-fp32 \ + --expect samples/hello/baselines/float_pinned_32/hash_stream.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 534b9f2..f6a5860 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,42 +330,112 @@ jobs: 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). + # M1-DET-04 (FR-11.5; AGENTS ARCH-010, TEST-004): bit-exactness CI. + # The M1-SAMPLE-01 hello scenario is run headless across the + # determinism matrix and asserted for per-tick hash identity: + # + # vs the committed baselines (samples/hello/baselines/; reference + # build: canonical Debug g++) — the P0 OS jobs' ctest already + # runs this half on every platform (tests/sample: + # hello_baseline_fpx / hello_baseline_fp32); this job pins the + # reference build against its own baselines and adds the + # cross-build pairs no single P0 job can produce: + # + # pair A: g++ Debug vs clang++ Debug (the two Linux + # compilers) + # pair B: Debug+ASan vs g++ Release (the two + # configurations) + # + # ... x both SimMath backends (fixed_point_16_16 and + # float_pinned_32 — ADR 0002). A desynced pair is declared + # unsupported for float_pinned_32 (same-build/same-ISA + # scope); fixed_point_16_16 must always match. The result is + # recorded in docs/benchmarks/determinism-matrix.md (scope + # stated 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 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug - - name: Build - run: cmake --build build -j + # -- The four build configurations (targeted builds: only the + # scenario binaries and the checker — no full project build). + # The hello binaries land at the SOURCE-tree path + # samples/hello/bin/ (shared across build trees), so each + # tree's binaries are copied into detbin/ immediately after + # that tree is built. + - name: Build reference (Debug g++) + run: | + cmake -S . -B build-det-ref \ + -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ + cmake --build build-det-ref -j \ + --target hello hello-fp32 laige-detcheck + mkdir -p detbin + cp samples/hello/bin/hello detbin/hello-gcc-debug + cp samples/hello/bin/hello-fp32 detbin/hello-fp32-gcc-debug + cp build-det-ref/bin/laige-detcheck detbin/laige-detcheck + - name: Build clang++ Debug + run: | + cmake -S . -B build-det-clang \ + -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ + cmake --build build-det-clang -j --target hello hello-fp32 + cp samples/hello/bin/hello detbin/hello-clang-debug + cp samples/hello/bin/hello-fp32 detbin/hello-fp32-clang-debug + - name: Build Debug + ASan (clang++) + run: | + cmake -S . -B build-det-asan \ + -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ \ + -DLAIGE_ASAN=ON + cmake --build build-det-asan -j --target hello hello-fp32 + cp samples/hello/bin/hello detbin/hello-asan-debug + cp samples/hello/bin/hello-fp32 detbin/hello-fp32-asan-debug + - name: Build Release (g++) + run: | + cmake -S . -B build-det-release \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=g++ + cmake --build build-det-release -j --target hello hello-fp32 + cp samples/hello/bin/hello detbin/hello-gcc-release + cp samples/hello/bin/hello-fp32 detbin/hello-fp32-gcc-release + # -- The reference build must reproduce its own committed baselines + # (a stale baseline is a loud job failure, never a silent + # re-baseline — CORE-008). + - name: Reference baseline sanity + run: | + ./detbin/hello-gcc-debug \ + --expect samples/hello/baselines/fixed_point_16_16/hash_stream.txt + ./detbin/hello-fp32-gcc-debug \ + --expect samples/hello/baselines/float_pinned_32/hash_stream.txt + # -- The checker's built-in synthetic scenario self-check (two + # in-process runs; the M0-TOOL-02 step's Verify clause). - name: Synthetic scenario self-check - run: ./build/bin/laige-detcheck --scenario=synthetic - - name: Real scenario (M1-SAMPLE-01) - # The hello template (samples/hello) is always built in this - # job, so this step always executes: two no-arg runs of the - # scenario — both must exit 0 and print the 301-line per-tick - # hash stream (the guard is defensive only). 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: ./detbin/laige-detcheck --scenario=synthetic + # -- The two-configuration pairs (the roadmap matrix), both + # backends. Phase 1 spawns the two runs and streams their + # per-tick hash lines into one combined file (the children + # inherit the redirected stdout); phase 2 compares it offline + # (the Windows handle-delivery workaround; this job is + # ubuntu-only, so phase 1 is a plain bash redirection). + - name: Pair A — g++ Debug vs clang++ Debug + run: | + ./detbin/laige-detcheck \ + --run-a=detbin/hello-gcc-debug --run-b=detbin/hello-clang-debug \ + > detbin/combined-a-fpx.txt + ./detbin/laige-detcheck --compare-combined=detbin/combined-a-fpx.txt + ./detbin/laige-detcheck \ + --run-a=detbin/hello-fp32-gcc-debug --run-b=detbin/hello-fp32-clang-debug \ + > detbin/combined-a-fp32.txt + ./detbin/laige-detcheck --compare-combined=detbin/combined-a-fp32.txt + - name: Pair B — Debug+ASan vs Release 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 + ./detbin/laige-detcheck \ + --run-a=detbin/hello-asan-debug --run-b=detbin/hello-gcc-release \ + > detbin/combined-b-fpx.txt + ./detbin/laige-detcheck --compare-combined=detbin/combined-b-fpx.txt + ./detbin/laige-detcheck \ + --run-a=detbin/hello-fp32-asan-debug --run-b=detbin/hello-fp32-gcc-release \ + > detbin/combined-b-fp32.txt + ./detbin/laige-detcheck --compare-combined=detbin/combined-b-fp32.txt diff --git a/docs/README.md b/docs/README.md index 685c0e9..0c01484 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,12 @@ temp+rename, size-bounded), `Engine::startReplayRecording`, and template game (samples/hello: one component, one system, one entity, the per-tick `World::stateHash` stream on stdout, record → replay through the scenario's own binary, the PRD §9.4 line budget — -[samples/hello/README.md](../../samples/hello/README.md)). +[samples/hello/README.md](../../samples/hello/README.md)); +M1-DET-04: bit-exactness CI — the hello scenario's committed per-tick +hash baselines (both SimMath backends), the `hello --expect` baseline +check in every P0 OS job's ctest, the merge `detcheck` job's +two-configuration pairs (g++ vs clang++, Debug+ASan vs Release), and +the [determinism report](benchmarks/determinism-matrix.md)). 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. @@ -142,7 +147,8 @@ still to land. (M0-CORE-06). - [Determinism checker](api/detcheck.md) — the `laige-detcheck` tool and the scenario hash-line contract (` ` lines, two build - configurations) (M0-TOOL-02). + configurations) (M0-TOOL-02; activated on the hello scenario by + M1-DET-04 — the baseline comparison and the CI matrix). ## Guides @@ -166,8 +172,13 @@ still to land. report fields, the `budgets.json` field mapping, the baseline-file convention, and the PRD §8.1 regression policy (M0-DOC-01). - [Baselines](benchmarks/baselines/README.md) — recorded baseline - reports. **Empty so far** (all `budgets.json` entries have - `measured: 0`); the first, `m0-synthetic.md`, lands with M0-EXIT-01. + reports (`m0-synthetic.md`, M0-EXIT-01, and `m1-ecs-stress.md`, + M1-ECS-07; every `budgets.json` entry still has `measured: 0`). +- [Determinism matrix](benchmarks/determinism-matrix.md) — the + M1-DET-04 determinism report: the committed per-tick hash baselines + of the hello scenario (both SimMath backends), the CI matrix (every + P0 OS job's baseline check + the merge detcheck job's cross-compiler + / sanitizer / Release pairs), and the ARCH-010 scope statement. ## Architecture decisions (ADRs) diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index e22f9c5..d2af3c5 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -17,8 +17,13 @@ output contract** defined below and compares two such streams. The real scenario now exists: **M1-SAMPLE-01**'s `hello` (samples/hello) prints exactly this contract — the tick-0 line plus one `World::stateHash` line per completed tick — and the CI job's real- -scenario step runs it; **M1-DET-04** activates the two-configuration -comparison of it (the tool's line-by-line comparison is unchanged). +scenario stream in CI: **M1-DET-04** activated the checker on it — the +merge CI job runs the two-configuration matrix (g++ vs clang++ Debug, +Debug+ASan vs Release, both SimMath backends; see [CI status](#ci-status)), +and every P0 OS job's ctest asserts the stream against the committed +per-tick hash baselines (`samples/hello/baselines/`) with `hello +--expect` ([baseline comparison](#baseline-comparison-hello---expect)). +The tool's line-by-line comparison is unchanged. ## Scenario contract @@ -186,17 +191,69 @@ 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`). +## Baseline comparison (`hello --expect`) + +`laige-detcheck` compares **two runs of one build configuration** +against each other; the committed baselines (below) compare a run +against the **reference build**. The scenario's own binary carries that +check — `hello --expect BASELINE` — because a game scenario's log +cannot be replayed by `laige-replay` (different registrations → replay +identity mismatch; ADR 0002). It is the scenario-side equivalent of +`laige-replay --expect` and follows the same contract: + +- stdout is the run's 301-line hash stream (unchanged); +- **exit 0** — every line matches the baseline (identity); +- **exit 1** — first divergence, with the `laige-replay --expect` + report on stderr (`hash mismatch at tick N (first divergence)` + the + baseline/run lines, or the stream-length-mismatch report); +- **exit 2** — a baseline read/contract error (missing file, a line + not matching ` `, the 65536-line bound, the 64-byte + line bound, the 8 MiB read cap) before any tick is run. + +The check is O(1) per tick in place (one line compared at a time; no +allocation after the baseline loads). + +The committed per-tick hash baselines live in +[`samples/hello/baselines/`](../../samples/hello/baselines/README.md) — +one stream per SimMath backend, generated from the **reference build +(canonical Debug g++)**: + +- `fixed_point_16_16/hash_stream.txt` — bit-exact by the C++20 + standard; every conforming build must reproduce it; +- `float_pinned_32/hash_stream.txt` — same-build/same-ISA scope (ADR + 0002); a desynced build is declared **unsupported** for that backend + on its platform (never re-baselined silently). + ## 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). It also runs the **real -scenario** (M1-SAMPLE-01): both `--run-a` and `--run-b` point at -`samples/hello/bin/hello` (the no-arg 300-tick headless run — the step -activates once the sample binary exists, which it now does), so every -PR and merge executes the template game's detcheck contract: both runs -must complete (exit 0) and print the 301-line hash stream. **M1-DET-04** -replaces this same-configuration step with the two-configuration -comparison (`build-asan/bin/hello` vs `build/bin/hello`) and records the -result per ARCH-010. +M1-DET-04 wired the determinism matrix into CI (PRD §14 cadence — every +merge; the result is recorded per ARCH-010 in +[benchmarks/determinism-matrix.md](../benchmarks/determinism-matrix.md)): + +- **Every P0 OS job** (merge: all five; PR: the labelled one) runs the + full ctest suite, which includes `hello_baseline_fpx` / + `hello_baseline_fp32` — this job's own native build of the scenario + must reproduce the reference baselines bit-exactly, on both backends + (`hello --expect`, above). The failure fixtures (first divergence, + stream length, malformed line, missing file) are pinned by + `hello_baseline_mismatch` / `_truncated` / `_malformed` / `_missing`. +- **The merge `detcheck` job** (`.github/workflows/ci.yml`, + "Determinism check" — a tooling job on every merge, like + `include-lint` and `api-manifest`, independent of the `ci:*` label + selector): builds the scenario in four configurations (Debug g++, + Debug clang++, Debug+ASan clang++, Release g++ — only the scenario + targets + the checker), then runs + 1. the **reference-baseline sanity** — the Debug g++ build must + reproduce both committed baselines (a stale baseline is a red + job, CORE-008); + 2. the built-in **synthetic self-check** (two in-process runs — the + M0-TOOL-02 Verify clause); + 3. the two-configuration **pairs**, both backends, via + `--run-a/--run-b` + `--compare-combined`: + **pair A** g++ Debug vs clang++ Debug (the two Linux compilers), + **pair B** Debug+ASan vs Release (the two configurations). +- **The PR `detcheck` job** (`.github/workflows/ci-pull.yml`) keeps the + single-build shape (the synthetic self-check) plus the + **both-backend baseline comparison** (`hello --expect` / + `hello-fp32 --expect`) — so the check always runs, even on PRs + labelled for a non-Linux P0 OS. diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 70c3830..18b7acb 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -15,6 +15,12 @@ Benchmark methodology, baselines, results, and the regression policy `baselines/m1-ecs-stress.md`, M1-ECS-07) measure harness and stress workloads that update no budget. `baselines/m1-profiler-cost.md` lands with M1-PROF-01. +- [determinism-matrix.md](determinism-matrix.md) — the **determinism + report** for the M1-SAMPLE-01 hello scenario (M1-DET-04): the + committed per-tick hash baselines (both SimMath backends), the CI + matrix (every P0 OS job's ctest baseline check + the merge detcheck + job's cross-compiler / sanitizer / Release pairs), the ARCH-010 + scope statement, and the `float_pinned_32` per-platform support list. - Per-milestone performance results (M1 10k-entity tick, M2 50k-sprite scene, M6/M7 zone server, …) land here as their milestones close — see the [roadmap progress board](../../roadmap/README.md). diff --git a/docs/benchmarks/determinism-matrix.md b/docs/benchmarks/determinism-matrix.md new file mode 100644 index 0000000..f54a5ea --- /dev/null +++ b/docs/benchmarks/determinism-matrix.md @@ -0,0 +1,180 @@ +# Determinism matrix — M1-SAMPLE-01 hello scenario (M1-DET-04) + +Recorded by **M1-DET-04** (2026-09-17). This is the determinism report +required by the step's scope: the M1-SAMPLE-01 hello scenario is +activated in `laige-detcheck` with a committed per-tick hash-stream +baseline, and every P0 OS CI job asserts per-tick identity against it. +The guarantee's **scope is stated here per ARCH-010** (a determinism +guarantee without a scope is a defect). + +## What the matrix asserts + +The scenario emits one ` ` line per tick (301 lines: tick 0 += initial state, then one per completed tick) — the +[detcheck scenario contract](../api/detcheck.md). The per-tick hash is +`World::stateHash` (FNV-1a 64 over tick, live handles, archetype +signatures + raw component bytes, per-system PRNG state). Two runs are +identical iff inputs + seed + math backend + config (+ component schema +hash) match — the replay identity (ADR 0002). The matrix checks that +identity across: + +1. **Every P0 OS job (merge and PR):** the job's native build runs + `hello --expect` and `hello-fp32 --expect` against the committed + baselines (`samples/hello/baselines/`) in its ctest suite (tests + `hello_baseline_fpx`, `hello_baseline_fp32`). A platform whose build + desyncs from the reference build fails there — loudly. +2. **The merge `detcheck` job (ubuntu-24.04):** four cross-build pairs, + both backends (`.github/workflows/ci.yml`, job "Determinism check"): + + | Pair | Run A | Run B | Proves | + |---|---|---|---| + | A1 | g++ `Debug` | clang++ `Debug` | cross-**compiler** (Linux) | + | A2 | g++ `Debug` | clang++ `Debug` | cross-compiler, `float_pinned_32` | + | B1 | clang++ `Debug`+ASan | g++ `Release` | cross-**configuration** (+ sanitizer) | + | B2 | clang++ `Debug`+ASan | g++ `Release` | cross-configuration, `float_pinned_32` | + + each via `laige-detcheck --run-a/--run-b` + `--compare-combined` + (two-stage, per-tick comparison; exit 0 match / 1 diverged / 2 error). +3. **Reference-baseline sanity (same job):** the canonical Debug g++ + build must reproduce its own committed baselines on every merge — a + stale baseline is a red job, never a silent re-baseline (CORE-008). + +## AGENTS §12 metadata + +| # | Field | Value | +|---|---|---| +| 1 | Hardware | AMD Ryzen 9 7950X3D (16 cores / 32 threads), 64 GB RAM | +| 2 | OS | CachyOS (Arch-based Linux), x86_64 | +| 3 | Compiler and version | `g++ (GCC) 16.2.1 20260810`; `clang++ 22.1.8` (pairs A); `clang++ 22.1.8 -fsanitize=address,undefined` (pair B, run A); `g++ 16.2.1` (pair B, run B) | +| 4 | Build type | `Debug` (reference + pair A + pair B run A with ASan); `Release` (pair B run B) | +| 5 | Relevant flags | Engine policy (NFR-8.10): `-Wall -Werror -fno-exceptions -fno-rtti`; SimMath pinned set (ADR 0002): `-ffp-contract=off -fno-associative-math`; ASan pair: `-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer` | +| 6 | Dataset / workload | hello scenario: 1 entity, 1 component (`PlayerPos{M::Vec2}`), 1 system (`MovePlayer`: +1 unit/tick diagonal, wrap in a 32-unit box); 300 ticks, 60 Hz, seed `0x1F055EED` (the canonical sample config, `samples/hello/config.json`) | +| 7 | Warm-up | n/a (state hashes, not timings) | +| 8 | Sample count | 301 hash lines per run (tick 0 + 300 completed ticks); 4 pairs × 2 backends compared locally, 4 pairs × 2 backends in CI | +| 9 | Summary statistics | See the result tables below — the "metric" is bit-identity (0/1), not a distribution | +| 10 | Before / after | `before=n/a` (first recording) · `after=4/4 pairs OK, 2/2 baselines reproduced` (local, 2026-09-17) · `target=all pairs OK on every merge, both backends, all P0 OS jobs` | + +## Determinism scope (ARCH-010) + +| Backend | Guarantee scope | Basis | +|---|---|---| +| `fixed_point_16_16` | **Cross-platform, cross-compiler, cross-configuration:** any conforming C++20 build of this repo reproduces the stream (every P0 OS, both Linux compilers, Debug/Release, ASan/TSan lanes). | Q16.16 is signed integer arithmetic — exact by the C++20 standard, no compiler freedom. The matrix (rows 1–3 above) is the proof. | +| `float_pinned_32` | **Same-build/same-ISA by scope** (ADR 0002); the per-platform support list below is generated from the matrix results. A desynced pair is declared unsupported — the baseline is never re-fitted to the diverging build. | IEEE 754 single-precision; identical streams require identical rounding behavior. This scenario's op surface is single-rounding add/sub with comparisons — no FMA, no reassociation (pinned flags) — so identity is expected on all P0 platforms; the matrix confirms it. | + +## `float_pinned_32` per-platform support list + +Generated from the matrix (the first row set lands from the first merge +run of the detcheck job and the P0 jobs; entries are added/changed only +by matrix results or a documented baseline regeneration). + +| Platform | Build | Baseline check (ctest) | detcheck pairs | Status | +|---|---|---|---|---| +| linux-gcc | g++ `Debug` | reproduced (local, 2026-09-17) | pair A/B run A (local, 2026-09-17) | **supported** | +| linux-clang | clang++ `Debug` | reproduced (local, 2026-09-17) | pair A/B run A (local, 2026-09-17) | **supported** | +| linux-gcc (Debug+ASan / Release lanes) | clang++ `Debug+ASan`, g++ `Release` | reproduced (local, 2026-09-17) | pair B (local, 2026-09-17) | **supported** | +| macos-arm64 | AppleClang `Debug` | pending first merge run | pending (P0 job baseline check) | pending — expected supported | +| macos-intel | AppleClang `Debug` | pending first merge run | pending (P0 job baseline check) | pending — expected supported | +| windows-msvc | MSVC `Debug` | pending first merge run | pending (P0 job baseline check) | pending — expected supported | + +If a pending platform's `hello_baseline_fp32` fails on its P0 job, that +platform is moved to **unsupported** in this table (ADR 0002: +`float_pinned_32` is then excluded from its determinism guarantee — +games on that platform use `fixed_point_16_16`, which has no such +restriction) and the test is converted to a documented skip with a +pointer to this table. `fixed_point_16_16` failures are never skipped: +they are engine bugs (CORE-008). + +## Local evidence (2026-09-17, this recording) + +Baselines generated from the canonical Debug g++ tree (`build/`): + +```console +$ ./samples/hello/bin/hello \ + > samples/hello/baselines/fixed_point_16_16/hash_stream.txt +$ ./samples/hello/bin/hello-fp32 \ + > samples/hello/baselines/float_pinned_32/hash_stream.txt +$ wc -l samples/hello/baselines/*/hash_stream.txt + 301 samples/hello/baselines/fixed_point_16_16/hash_stream.txt + 301 samples/hello/baselines/float_pinned_32/hash_stream.txt +``` + +Cross-compiler spot check (clang++ tree's streams vs the g++ baselines, +both backends): `cmp` reports byte-identical. + +The four detcheck pairs, both backends (each: `--run-a/--run-b` phase 1 ++ `--compare-combined` phase 2): + +```text +detcheck scenario=combined result=OK ticks=301 (pair A, fixed_point_16_16) +detcheck scenario=combined result=OK ticks=301 (pair A, float_pinned_32) +detcheck scenario=combined result=OK ticks=301 (pair B, fixed_point_16_16) +detcheck scenario=combined result=OK ticks=301 (pair B, float_pinned_32) +``` + +Reference-baseline sanity (the merge job's first check): + +```console +$ ./samples/hello/bin/hello --expect samples/hello/baselines/fixed_point_16_16/hash_stream.txt +# exit 0; hello headless ticks=300 status=ok +$ ./samples/hello/bin/hello-fp32 --expect samples/hello/baselines/float_pinned_32/hash_stream.txt +# exit 0; hello headless ticks=300 status=ok +``` + +**Perturbation proof (the step's Verify clause):** changing the +scratch system's SimMath constant (`kVelocity` 1 → 2 units/tick in +`MovePlayer`), rebuilding the canonical tree, and re-running +`hello --expect` fails exactly as the matrix requires — + +```text +hello: hash mismatch at tick 1 (first divergence) + baseline: 1 34ad0d068f9da541 + run: 1 d64c528f09f771f1 +# exit 1 +``` + +— and reverting the constant restores exit 0. The failure paths are +pinned by ctest in every P0 job (tests `hello_baseline_mismatch` — +first-divergence report, `hello_baseline_truncated` — stream-length +mismatch, `hello_baseline_malformed` — load-time contract error, +`hello_baseline_missing` — read error; the same paths `laige-replay +--expect` exercises in `tests/replay`). + +## Regeneration policy + +See [samples/hello/baselines/README.md](../../samples/hello/baselines/README.md): +regenerate only for a deliberate documented scenario/config/engine-hash +change, always from the canonical Debug g++ tree, always with the +cross-compiler identity re-verified, and always recorded here with the +reason and commit. This file gains one "Regeneration" subsection per +event (methodology §4: reports are append-only history). + +## First GitHub run (PR #41, 2026-09-17) + +The step's PR run (`CI (pull request)`, run 35238175154) — the first +GitHub execution of the matrix: + +- **Linux x64 (g++), Linux x64 (clang++), Linux x64 ASan+UBSan, Linux + x64 TSan** — all green, each running the full 82-test ctest suite + including the six `hello_baseline_*` tests (per-tick identity of the + job's own build against both committed baselines, plus the failure + fixtures). Job log (Linux x64 g++): `73/82 hello_baseline_fpx + Passed`, … `78/82 hello_baseline_missing Passed`. +- **Determinism check (tooling)** — green: synthetic self-check + (`detcheck scenario=synthetic result=OK ticks=256`) followed by the + both-backend baseline comparison (`hello --expect` / + `hello-fp32 --expect`, both exit 0, `hello headless ticks=300 + status=ok`). +- macOS/Windows P0 jobs: skipped without `ci:*` labels on the PR (the + label selector); they run on the merge via `ci.yml` (all P0 jobs). + +## Open until the first merge + +- The merge `detcheck` job (the four-configuration matrix: pair A + g++ vs clang++, pair B Debug+ASan vs Release, both backends, plus + the reference-baseline sanity) and the macOS/Windows P0 baseline + checks run with this step's merge (`ci.yml`). Until + then, the support-list rows marked *pending* above are projections + from the local evidence plus the PR run above; the first merge run + converts them into matrix results (or, for a desynced + `float_pinned_32` pair, into an "unsupported" declaration per + ADR 0002). diff --git a/docs/concepts/determinism.md b/docs/concepts/determinism.md index 6d50b04..c1527bf 100644 --- a/docs/concepts/determinism.md +++ b/docs/concepts/determinism.md @@ -219,7 +219,13 @@ struct EngineConfig { 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). +- Cross-target (M1-DET-04, done): the detcheck matrix over both + backends — every P0 OS job's ctest asserts the hello scenario's + per-tick hash stream against the committed baselines on both + backends, and the merge detcheck job adds the cross-compiler / + sanitizer / Release pairs. Scope and results: + [benchmarks/determinism-matrix.md](../benchmarks/determinism-matrix.md) + (ARCH-010). ## Related diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index fc43f77..b5294dc 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -38,8 +38,9 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) | Benchmarks | `./build/bin/laige-bench --suite=` | | Determinism check | `./build/bin/laige-detcheck --scenario=` | | Replay runner | `./build/bin/laige-replay --log LOG --config CONFIG.json [--expect BASELINE]` | -| hello template (run) | `./samples/hello/bin/hello [--replay LOG]` | -| hello template (replay) | `./samples/hello/bin/hello --log LOG` | +| hello template (run) | `./samples/hello/bin/hello [--replay LOG] [--expect BASELINE]` | +| hello template (replay) | `./samples/hello/bin/hello --log LOG [--expect BASELINE]` | +| hello template (fp32 backend) | `./samples/hello/bin/hello-fp32 [--replay LOG] [--expect BASELINE]` | | 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` | @@ -63,12 +64,20 @@ Notes: reserved, so no step can drift them. The `hello` rows are the M1-SAMPLE-01 template game (samples/hello): it builds into the source-tree path `samples/hello/bin/hello` (its own CMake target - property — the CI detcheck step invokes it from the repository + property — the CI detcheck job invokes it from the repository root with no arguments), prints the 301-line per-tick state-hash stream on stdout (the detcheck scenario contract — - [docs/api/detcheck.md](../api/detcheck.md)), and exits 0/1/2; the - `hello_*` CTest entries (`tests/sample`) are its CI form, and - `--replay` is a debug-build feature. Contract: + [docs/api/detcheck.md](../api/detcheck.md)), and exits 0/1/2. + `--expect BASELINE` compares the stream against a committed + per-tick hash baseline with the `laige-replay --expect` contract + (0 match, 1 first-divergence report, 2 baseline read/contract + error) — M1-DET-04's baseline check; the committed streams live in + `samples/hello/baselines/` (one per backend; the reference build is + canonical Debug g++). `hello-fp32` is the same game on the + `float_pinned_32` backend (ADR 0002 — same source, the backend + swapped by the build). The `hello_*` CTest entries (`tests/sample`) + are its CI form — including the baseline tests every P0 OS job + runs — and `--replay` is a debug-build feature. Contract: [samples/hello/README.md](../../samples/hello/README.md). Fuzz and randomized-test seeds: fixed default `0x1F055EED`, overridable (`laige-fuzz --seed=…`; tests via the `LAIGE_TEST_SEED` @@ -271,9 +280,16 @@ pinned set and the NaN/Inf policy): (` ` 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). + the self-check on every PR and merge, and — with M1-DET-04 — the + real-scenario determinism matrix: every P0 OS job's ctest asserts + the hello scenario's + per-tick hash stream against the committed baselines on both SimMath + backends (`hello --expect` — the `hello_baseline_*` entries of + `tests/sample`), and the merge `detcheck` job adds the two- + configuration pairs (g++ vs clang++ Debug, Debug+ASan vs Release, + both backends, via `--run-a/--run-b` + `--compare-combined`). + Scope and results: + [docs/benchmarks/determinism-matrix.md](../benchmarks/determinism-matrix.md). - `test_infra` is the M0-TEST-01 CTest entry (`tests/testing`): the `SeededRandom` suite of the `test_infra_tests` executable pins known-answer hashes for the seed-handling convention (the default seed diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 39ceebd..0d15f0a 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -193,7 +193,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R replay_replay` green; perturbed baseline makes `laige-replay --expect` fail at the correct tick with an actionable report. - **Size:** ~250 lines + tests -- [ ] **M1-DET-04 · Bit-exactness CI (all P0 OS jobs, both SimMath backends)** +- [x] **M1-DET-04 · Bit-exactness CI (all P0 OS jobs, both SimMath backends)** - **Refs:** FR-1.4, NFR-8.3, FR-11.5; PRD §14 (determinism every merge) - **Depends:** M1-DET-03, M1-SAMPLE-01, M0-TOOL-02 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index e5e2308..85f94b7 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 | 17 | 🚧 in progress (M1-SAMPLE-01) | +| M1 | 25 | 18 | 🚧 in progress (M1-DET-04) | | 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** | **35** | | +| **Total** | **193** | **36** | | --- @@ -211,6 +211,7 @@ One line per completed (or split/renumbered) step. | 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. | | 2026-09-16 | M1-DET-02 | `817ebe9` | Replay recorder (FR-1.4, FR-11.3, PRD Appendix A — replay = input log + seed — ADR 0002, ARCH-007, SCALE-005; M1-DET-02 scope, nothing else; replay EXECUTION — `world.state_hash` + the `laige-replay` runner — is M1-DET-03): the versioned replay LOG FORMAT (v1; `src/laige-sim/include/laige/sim/replay.h` + `replay.cpp`): 40-byte header — magic `LGRP`, `formatVersion` u16 (= 1, the single version gate — unsupported versions rejected explicitly, ARCH-007), reserved u16, then the ADR 0002 REPLAY IDENTITY: seed u64, tickRateHz u32, `componentSchemaHash` u64, `mathBackendId` u32, `configHash` u64 — + per-tick frame records (tick u64, strictly sequential from 1; byteLength u32 ≤ `kMaxReplayFrameBytes` = 1 MiB; the payload an OPAQUE byte blob — M1 frames are zero-length, the input data shape lands with M3-INPUT-03) + 16-byte trailer (frameCount u64 + `fileHash` u64 = canonical byte-stream FNV-1a 64 over every prior byte — truncation and bit rot self-detected); all integers little-endian on every platform (SCALE-005: byte order specified, not assumed); the PARSER is total over malformed input: every structural violation (size < header, null data, bad magic, unsupported version, non-zero reserved, frame length over cap — checked before any overrun read, payload past the body, tick not previous+1, trailer count mismatch, fileHash mismatch, trailing garbage) is a `MalformedInput` Status — never a crash, never a silent skip (CORE-008/TEST-005); the IDENTITY HASHES are pure integers (ARCH-010: no addresses, no wall clock) — word-stream FNV-1a 64, big-endian byte order per u64 (the house convention: determinism state hashes, Prng golden vectors, laige-detcheck): `componentSchemaHash(World)` over [componentCount, then per registered type in id order: id, size, alignment] (a function of the REGISTRATION order — same types same order → same hash; stack-only, 769 words max, no allocation), `configHash(EngineConfig)` over [tag 1, tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math] (M1-CFG-01 refines the encoding with the schema, under the format's versioning), `makeReplayIdentity(World, EngineConfig)` assembles the header; the REPLAY RECORDER (`ReplayRecorder`, move-only: `create(identity, path, maxBytes)` → `writeFrame(tick, data, len)` → `finish()`): writes `path + ".tmp"` (same filesystem — the final `rename` is atomic; MSVC `MoveFileExA(MOVEFILE_REPLACE_EXISTING)`) and publishes `path` only on a successful finish — an interrupted/failed recorder leaves NO file at the final path (temp removed by the destructor; a rename failure leaves the temp for inspection, documented), SIZE-BOUNDED (maxBytes counts header + frames + trailer together; 0 = `kDefaultReplaySizeLimit` 128 MiB; a cap below `kMinReplaySizeLimit` = 56 rejected at create as `InvalidArgument`; a cap breach mid-run is `BudgetExhausted`, sticky — every later call returns the same Status; `finish()` on a cap that cannot fit the trailer is the same error), strict 1,2,3,... tick sequence (`InvalidArgument` otherwise), frame over 1 MiB `InvalidArgument`, empty path `InvalidArgument`, open/write/rename I/O `IoError`; the READER: `parseReplay(const uint8_t*, size)` (in-memory, O(size), one output allocation per frame) and `loadReplay(path, maxBytes = default)` (bounded read — a file larger than maxBytes is a `MalformedInput`, the ADR 0003 JSON-bound precedent; missing file `IoError`, empty path `MalformedInput`); the ENGINE WIRING (`engine.{h,cpp}`): `Engine::startReplayRecording(path, maxBytes)` — called ONCE, after all component/system registration, before `run_headless` (the identity is captured from the live world + config at call time — a later registration makes the recorded schema hash stale, the schedule-stale precedent, M1-SYS-02) — OPT-IN and DEBUG BUILDS ONLY (`NDEBUG` → `InvalidArgument` + one `replay/record_disabled` warn — recording is development tooling; the format and wiring exist in every build, the opt-in does not), disabled cost one null check per tick (PERF-003/LOG-003), enabled cost one bounded stdio write per completed tick (the explicit, opt-in, visible cost — PERF-002); the loop's `onTick` hook writes one ZERO-LENGTH frame per COMPLETED tick (M1: no input system yet — the frame bytes are the future input blob); a recording failure mid-run STOPS the run: `run_headless` returns the recorder's Status (≤ 1 frame of extra ticks, the loop's bounded frame contract), the log is NOT published (no partial file at the final path), and the ordered shutdown still runs (CONC-006); a successful bounded run finalizes (trailer + atomic rename) and logs `replay/record_finished` (Info: path, bytes, frames); a failed run's shutdown logs `replay/record_aborted` (Warn: path, bytes) and discards the temp; a second start is `InvalidArgument` + `replay/record_already_started` (Warn); a stopped engine's start is a no-op failure WITHOUT logging (the stopped-state precedent); accessors `replayRecordingActive()`/`replayBytesWritten()`; move ctor/assign transfer the active recorder (move-assign abandons the source's via shutdown); structured events, subsystem `replay` (NFR-13.3 5-field grammar, rate-limited where repeated): `record_started` (Info: path, size_limit, seed, math_backend, config_hash, schema_hash), `record_finished` (Info), `record_failed` (Error: path, tick, error), `record_aborted` (Warn), `record_already_started` (Warn), `record_start_failed` (Warn), `record_disabled` (Warn, release builds); the CLI (`tools/run/laige-run.cpp`): `--replay LOG` is now REAL (it was the M1-HEAD-01 stub — accepted, warned, ignored): laige-run registers no game components (the built-in registration is complete at `create`), so the identity capture is at the right phase; on a clean bounded run the log is atomically published at LOG; a start failure exits 2 (`laige-run: replay: {error text}`, the run did not happen), a mid-run failure exits 1 (the `status=` summary line carries the error name) with no partial log; usage text updated; the FUZZ TARGET (`tools/fuzz/laige-fuzz.cpp`): `replay_parse` (any Status ok — only a crash/sanitizer report fails the run, the runner contract) — the parser's malformed-input surface (TEST-005, NFR-8.7: the SCALE-005 parser fuzz requirement) — 1000 deterministic runs in `fuzz_replay_parse` (TIMEOUT 120, TSan `halt_on_error=1` in the TSan tree); the corpus gains a valid v1 replay log (68 bytes: header + one zero-length frame + a correct FNV-1a trailer, local encoder with the house FNV constants) as a mutate/truncate base (the runner's generate-mutate-truncate modes feed the parser real-shaped input); laige-fuzz now links `laige-sim` (arrows only downward, PRD §10.1; laige-core's headers come through it — CPP-010); the TEST SUITE (`tests/laige-sim/replay_record_tests.cpp`, 22 tests, CTest entry `replay_record` — the step's Verify command; added to the TSan property list; NFR-8.10 static_assert self-checks): `ReplayFormat` — the round trip (record 8 PRNG-payload frames → raw bytes → parse → every identity field + frame byte identical — the step's "record N ticks → parse back → identical bytes" clause; the `replay-roundtrip frames=… bytes=… filehash=…` machine-greppable line; also: file form via `loadReplay`, deterministic re-encoding byte-identical, the zero-frame log, the EXACT 1 MiB frame boundary, and the full malformed table (every truncation cut of a valid log, bad magic, unsupported version, non-zero reserved, a length field of 1 MiB+1 — the overrun-read-preventing branch, out-of-sequence tick, trailer count mismatch, a flipped body byte (fileHash branch), trailing garbage, empty input, null data)); `ReplayRecorder` — atomic publish (final file + no temp), interruption leaves NO file (temp removed by the destructor), the size limit at the EXACT boundary (cap 56: frame 1 fits 52 ≤ 56, frame 2 breaches 64 > 56 → `BudgetExhausted`; sticky failure; no partial file; machine-checked), the size limit AT FINISH (cap 67: both frames fit exactly, the 16-byte trailer cannot → `BudgetExhausted`), the tick sequence (first tick 2, repeated tick 1 → `InvalidArgument`), write-after-finish and the one-shot finish, create validation (empty path, cap 55 below the minimum, the 0 = default cap + header read-back), move transfers the file (source inert, destination finishes); `ReplayIdentity` — schema hash stable for the same registration order, different for a different order (`replay-identity schema-order h1=… h3=…` line), config hash per-field sensitivity (seed/tick rate/capacity/churn/enabled/math), `makeReplayIdentity` echoes seed/tick rate/math id + both hashes; `ReplayEngine` — 8-tick run with recording ON → 8 zero-length frames (ticks 1..8) + identity matching the pre-run world+config capture (the `replay-engine ticks=… bytes=… schema=…` line; 152 bytes = 40 + 8×12 + 16), the mid-run failure stop (cap 56 → `run_headless` returns `BudgetExhausted`, no partial log, engine shut down, sink: `record_failed` = 1 + `record_aborted` = 1 — sink read BEFORE `restoreLogger`, the engine_tests pattern), double start (`InvalidArgument` + `record_already_started` = 1), stopped-engine start (`InvalidArgument`, NO log — the stopped-state precedent, zero accessors); CMake (`src/laige-sim/CMakeLists.txt`, `tests/laige-sim/CMakeLists.txt`, `tools/fuzz/CMakeLists.txt`): `replay.cpp` into `LAIGE_SIM_SOURCES` (step comment), `replay_record_tests.cpp` into `LAIGE_SIM_TEST_SOURCES` + the `replay_record` entry (unquoted gtest filter `Replay*`, the M1 pattern) + the TSan property list, the fuzz target + entry; `laige-api.json` regenerated (630 symbols from 20 headers; `api-real-tree` green); DOCS in the same change (DOC-007): new `docs/api/replay.md` (the format spec — layout, the malformed-input table, the identity hashes, the recorder contract, the readers, the engine + CLI integration, the Performance section (disabled = one null check/tick; enabled = one bounded stdio write/tick; the 1 MiB frame + 128 MiB total bounds), misuse warnings, testing/CI) linked from `docs/README.md` (API list + the per-module list + the M1 progress summary + the compatibility bullet); `docs/api/engine.md` (new "Replay recording (M1-DET-02)" section, the M1-DET-02/03 scope split fixed, the `--replay` CLI bullet de-stubbed, the misuse bullet, the Testing+CI entries); `docs/concepts/determinism.md` ("What it is not (yet)": recording landed, execution = M1-DET-03, PRNG introspection joins M1-DET-03's `state_hash`, Related link); `docs/compatibility/README.md` (the replay log is the FIRST persistent engine format — added to the formats table: version 1, magic `LGRP`, strict validation); `docs/testing.md` (the `replay_parse` target + its command-table row); `tools/README.md` (`--replay` de-stubbed, the fuzz target list); `src/laige-sim/README.md` status updated; ROADMAP: M1-DET-02 box checked (`M1-heartbeat.md`), progress board M1 14→15 of 25, total 34→35 (this line's hash is the first commit of the two-commit change; the progress board update is in that commit). Local Verify: `ctest -R replay_record` green (22/22, Debug), full ctest 57/57 on `build` (Debug GCC), `build-asan` (ASan+UBSan — leak-free, the recorder's interrupted-state cleanup proven), and `build-tsan` (TSan `halt_on_error=1`, incl. `replay_record` + `fuzz_replay_parse`), `laige-fuzz replay_parse --runs=1000` + `json_parse --runs=1000` clean (default seed), `tools/laige-include-lint` OK (36 source files, 1/10 vendored deps), `laige-api` regenerated with `api-real-tree` green, zero new warnings under NFR-8.10. Untested paths: the MSVC `_fsopen`/`MoveFileExA` and AppleClang branches (CI-only), the release-build `record_disabled` branch (not exercisable in a Debug tree — the `NDEBUG` path is a compile-time constant), and a real > 128 MiB run (the size-limit branches are boundary-tested at 56/67 instead). Compat: additive only — the M1-HEAD-01 `--replay` stub behavior is replaced by the real recording (the flag's documented contract was always this step); no existing symbol or behavior changed. | | 2026-09-17 | M1-SAMPLE-01 | `75d1df6` | hello.laige headless template game (NFR-13.5, PRD §9.4/§13, TEST-004, ADR 0002, ARCH-010, CORE-008): the first complete Laige game — samples/hello (hello.cpp: 99 code lines < the PRD §9.4 budget of 100, the ~76 NFR-13.5 comment lines do not count; one component PlayerPos + LAIGE_COMPONENT/LAIGE_DETERMINISM_SAFE at namespace scope, one system MovePlayer (LAIGE_SYSTEM; constant +1/tick per axis, wrapped in the [-16, 16] box), one entity at the box center; the canonical config (60 Hz / 8-entity / 256-churn / 0x1F055EED / fpx16_16) is embedded in the binary with config.json as its declarative record (hello_config_valid CTest runs laige-run --headless against it); the stdout stream is the detcheck scenario contract (tick-0 + one World::stateHash line per completed tick — 301 lines at 300 ticks, docs/api/detcheck.md), stderr = engine logging + the hello {headless|replay} ticks=N status=ok summary, exit 0/1/2; replay: --replay records its own run (debug builds only — the engine startReplayRecording policy parity, exit 2 under NDEBUG), --log loads the log, checks the replay identity against its own (world, config) via replayIdentityDiff, and re-runs the log's frame count through the same tick loop — record → replay is byte-identical (hello_replay CTest); laige-replay cannot replay a game log (it registers only the built-ins — different identity), so the scenario binary carries the mode (docs/api/replay.md); CI: tests/sample (9 CTest entries, TSan env blocks) — hello_scenario, hello_replay (Debug-gated), hello_replay_missing/_malformed, hello_mode_exclusive, hello_usage_unknown/_missing_value, hello_config_valid, hello_line_budget (CMake-core code-line counter: non-blank, non-// lines; semicolon-protected split, explicit whitespace class — CMake regexes have no POSIX classes); the detcheck job's real-scenario step self-activates (both no-arg runs must exit 0 — M1-DET-04 swaps in the build-asan vs build two-configuration comparison); docs: detcheck.md / replay.md / building.md / docs/README.md updated in the same change; local verification: Debug 76/76 + ASan 9/9 + Release gate + both CI forms byte-identical + determinism/include lints + api-manifest (no new public header) — additive only, no existing symbol or behavior changed. | +| 2026-09-17 | M1-DET-04 | `—` | Bit-exactness CI (FR-1.4/11.5, NFR-8.3, PRD §14, ARCH-010, TEST-004, ADR 0002): activates `laige-detcheck` with the real M1-SAMPLE-01 scenario — committed per-tick hash-stream baselines in `samples/hello/baselines/` (one per SimMath backend; reference build: canonical Debug g++; 301 lines each), `hello --expect BASELINE` as the scenario-side equivalent of `laige-replay --expect` (the scenario binary carries the check — a game log cannot be replayed by laige-replay; same 0/1/2 contract, `hello::BaselineCheck` in `hello-baseline.cpp`), and the `hello-fp32` build variant (same source, `float_pinned_32` via the `LAIGE_HELLO_BACKEND` macros — one op surface, two backends); CI: every P0 OS job's ctest now asserts per-tick identity against the baselines on both backends (`hello_baseline_fpx`/`hello_baseline_fp32`) plus the failure fixtures (`hello_baseline_mismatch`/`_truncated`/`_malformed`/`_missing` — first-divergence, stream-length, malformed-line, missing-file paths), the merge `detcheck` job builds four configurations (Debug g++, Debug clang++, Debug+ASan clang++, Release g++) and runs the two-configuration pairs (pair A: g++ vs clang++ Debug; pair B: Debug+ASan vs Release — both backends, `--run-a/--run-b` + `--compare-combined`) after a reference-baseline sanity check, and the PR `detcheck` job runs the both-backend baseline comparison (tooling cadence, label-independent); local Verify: full `ctest` 82/82 on the canonical tree, all four detcheck pairs OK (ticks=301), byte-identical clang++ vs g++ streams (both backends), perturbation proof — `kVelocity` 1→2 in `MovePlayer` makes `hello --expect` exit 1 with "hash mismatch at tick 1 (first divergence)", the revert restores exit 0; determinism report recorded in `docs/benchmarks/determinism-matrix.md` (AGENTS §12 metadata, ARCH-010 scope statement, the `float_pinned_32` per-platform support list generated from matrix results — desynced pairs declared unsupported, never re-baselined; regeneration policy); `hello.cpp` stays at 99 code lines (PRD §9.4 — the baseline check and the fp32 variant live in separate TUs); docs updated in the same change (detcheck.md baseline-comparison section + CI status, hello README, building.md canonical rows, concepts/determinism.md cross-target, docs/README.md, tools/detcheck comment); commit lands with the step's PR (#41) | --- diff --git a/samples/hello/CMakeLists.txt b/samples/hello/CMakeLists.txt index 392cc7a..2c0c62a 100644 --- a/samples/hello/CMakeLists.txt +++ b/samples/hello/CMakeLists.txt @@ -1,13 +1,25 @@ # hello.laige (M1-SAMPLE-01): the headless template game. # -# The binary lands at the SOURCE-TREE path samples/hello/bin/hello -# (RUNTIME_OUTPUT_DIRECTORY override): the CI detcheck step -# (.github/workflows/ci.yml, "Real scenario (M1-SAMPLE-01)") invokes -# it from the repository root with no arguments — the canonical -# config is embedded in the source, so no CWD-relative file access. -# The build-tree convention (/bin/) is unchanged for every -# other target. -add_executable(hello hello.cpp) +# The binaries land at the SOURCE-TREE paths samples/hello/bin/hello +# and samples/hello/bin/hello-fp32 (RUNTIME_OUTPUT_DIRECTORY override): +# the CI detcheck job (.github/workflows/ci.yml, "Determinism check") +# invokes them from the repository root with no arguments — the +# canonical config is embedded in the source, so no CWD-relative file +# access. The build-tree convention (/bin/) is unchanged for +# every other target. +# +# Two build variants of the SAME game source (M1-DET-04; ADR 0002: +# one op surface, two backends, the game code written once): +# +# hello fixed_point_16_16 (the canonical backend — the default; +# the config.json backend) +# hello-fp32 float_pinned_32 (hello-fp32.cpp #defines the two +# LAIGE_HELLO_BACKEND macros and includes hello.cpp) +# +# hello-baseline.cpp is the --expect baseline check (the M1-DET-04 +# determinism-matrix machinery; kept out of hello.cpp so the template +# stays inside the PRD §9.4 line budget). +add_executable(hello hello.cpp hello-baseline.cpp) laige_apply_engine_policy(hello) laige_apply_simmath_policy(hello) # laige-sim is the only internal dependency (PRD §10.1: arrows only @@ -15,3 +27,10 @@ laige_apply_simmath_policy(hello) target_link_libraries(hello PRIVATE laige-sim) set_target_properties(hello PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bin) + +add_executable(hello-fp32 hello-fp32.cpp hello-baseline.cpp) +laige_apply_engine_policy(hello-fp32) +laige_apply_simmath_policy(hello-fp32) +target_link_libraries(hello-fp32 PRIVATE laige-sim) +set_target_properties(hello-fp32 PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bin) diff --git a/samples/hello/README.md b/samples/hello/README.md index 0f7485c..b793ebd 100644 --- a/samples/hello/README.md +++ b/samples/hello/README.md @@ -13,7 +13,10 @@ order. |---|---| | `hello.laige` | The project manifest (provisional M1 format: `laige.project` v1 JSON. The asset-pipeline format it replaces is owned by M3-ASSET-01). | | `config.json` | The canonical sample config in the engine's declarative JSON surface (`laige-run`'s format, M1-HEAD-01). The values are identical to the config embedded in `hello.cpp` (see "Config" below). | -| `hello.cpp` | The game source (the only source). | +| `hello.cpp` | The game source. | +| `hello-baseline.cpp` | The `--expect` baseline check (M1-DET-04) — separate TU so `hello.cpp` stays inside the PRD §9.4 line budget. | +| `hello-fp32.cpp` | The `float_pinned_32` build variant (M1-DET-04, ADR 0002): defines the backend macros and includes `hello.cpp` — same game source, backend swapped by the build. | +| `baselines/` | The committed per-tick hash-stream baselines, one per backend (M1-DET-04; [baselines/README.md](baselines/README.md)). | | `LICENSE` | Per-sample license (ADR 0001: each sample ships its own license; MIT, matching the repository). | ## Build and run @@ -25,17 +28,19 @@ cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j ``` -The binary lands at the **source-tree** path `samples/hello/bin/hello` -(a `RUNTIME_OUTPUT_DIRECTORY` override — the CI detcheck step invokes it -from the repository root without arguments). Run it from anywhere. Note -the path is shared across build trees: building a second tree (e.g. -`build-asan/`) overwrites it, so rebuild the tree you intend to run -before executing the binary. +The binaries land at the **source-tree** path `samples/hello/bin/` +(a `RUNTIME_OUTPUT_DIRECTORY` override — the CI detcheck job invokes +them from the repository root without arguments). Run them from +anywhere. Note the path is shared across build trees: building a second +tree (e.g. `build-asan/`) overwrites it, so rebuild the tree you intend +to run before executing the binary. ```sh -./samples/hello/bin/hello # 300 ticks, no replay +./samples/hello/bin/hello # 300 ticks, no replay (fixed_point_16_16) ./samples/hello/bin/hello --replay LOG # 300 ticks + record LOG (debug builds) ./samples/hello/bin/hello --log LOG # replay LOG, print its hash stream +./samples/hello/bin/hello --expect BASELINE # compare the stream against a baseline +./samples/hello/bin/hello-fp32 # the same game on float_pinned_32 ``` - **stdout** carries exactly the hash stream — nothing else: `N+1` @@ -48,10 +53,14 @@ before executing the binary. - **stderr** carries the one-line summary (`hello headless ticks=300 status=ok` / `hello replay ticks=300 status=ok`) and diagnostics. - **Exit codes:** `0` ok; `1` a run failure (a tick failed, a replay - write failed, or the log finalization failed); `2` usage, IO, or - replay-identity error (nothing was run). -- `--replay` and `--log` are mutually exclusive; an unknown option or a - missing option value is a usage error (`2`). + write failed, the log finalization failed, or — with `--expect` — a + per-tick identity mismatch with the baseline, with the + first-divergence report on stderr); `2` usage, IO, or + replay-identity error (nothing was run), including a baseline + read/contract failure (`hello: baseline: ...`). +- `--replay` and `--log` are mutually exclusive; `--expect` composes + with either run form; an unknown option or a missing option value is + a usage error (`2`). ## The game @@ -87,14 +96,16 @@ runner for a game scenario, and `laige-replay`'s scope note The canonical sample config is **embedded** in `hello.cpp` (the aggregate at the top of `main`): 60 Hz, scene budget 8, the engine churn default (256), the house seed `0x1F055EED` -([docs/testing.md](../../docs/testing.md)), deterministic -`fpx16_16` — the only backend this template supports (selecting -`float_pinned_32` would make the config lie about the math the state -was computed in: the replay identity, ADR 0002). `config.json` is the -declarative record of the same values in the engine's JSON surface — -it is what `laige-run --headless samples/hello/config.json` consumes -and what the M1-DET-04 baseline tooling reads; the template binary -keeps its CLI minimal (CORE-004) and does not re-read it. +([docs/testing.md](../../docs/testing.md)), deterministic — the +backend field is `fixed_point_16_16` in `hello` and +`float_pinned_32` in the `hello-fp32` build variant (the +`LAIGE_HELLO_BACKEND_ID` macro, ADR 0002: the backend is part of the +replay identity, so each binary's stream belongs to exactly one +backend). `config.json` is the declarative record of the same values +in the engine's JSON surface — it is what +`laige-run --headless samples/hello/config.json` consumes and what the +M1-DET-04 baseline tooling reads; the template binary keeps its CLI +minimal (CORE-004) and does not re-read it. ## Replay @@ -119,6 +130,29 @@ Note: `laige-replay` (the engine tool) replays only logs recorded by log is replayed by the scenario's own binary — which is why this template carries the `--log` mode. +## Baselines and the determinism matrix (M1-DET-04) + +`--expect BASELINE` compares the run's per-tick hash stream against a +committed baseline with the `laige-replay --expect` contract (the +scenario's own binary carries the check because a game log cannot be +replayed by `laige-replay` — the replay identity, ADR 0002): +**exit 0** identity, **exit 1** first-divergence report (or stream- +length mismatch) on stderr, **exit 2** baseline read/contract error +before any tick is run. The committed baselines +([baselines/](baselines/README.md)) are the 301-line streams of the +**reference build (canonical Debug g++)**, one per backend; +`fixed_point_16_16` must be reproduced by every conforming build, +`float_pinned_32` is same-build/same-ISA (a desynced build is declared +unsupported — never re-baselined). + +In CI: every P0 OS job's ctest runs `hello_baseline_fpx` / +`hello_baseline_fp32` (this job's own build vs the baselines, both +backends) plus the failure fixtures, and the merge `detcheck` job adds +the two-configuration pairs (g++ vs clang++ Debug, Debug+ASan vs +Release, both backends, via `laige-detcheck --run-a/--run-b`). Scope +and results: +[docs/benchmarks/determinism-matrix.md](../../docs/benchmarks/determinism-matrix.md). + ## Tests `ctest --test-dir build -R '^hello'` (registered in `tests/sample`): @@ -134,6 +168,12 @@ template carries the `--log` mode. | `hello_usage_missing_value` | A dangling option: exit 2. | | `hello_config_valid` | `config.json` parses cleanly on the engine's config surface (`laige-run --headless` consumes it). | | `hello_line_budget` | The game-code budget (PRD §9.4): non-comment, non-blank lines of `hello.cpp` < 100. | +| `hello_baseline_fpx` | **M1-DET-04:** this build's `hello --expect` the committed `fixed_point_16_16` baseline: exit 0, 301 hash lines — per-tick identity vs the reference build on every P0 OS job. | +| `hello_baseline_fp32` | **M1-DET-04:** the same assertion for `hello-fp32` vs the `float_pinned_32` baseline. | +| `hello_baseline_mismatch` | **M1-DET-04:** a derived baseline with one flipped hash: exit 1, the `laige-replay --expect` first-divergence report. | +| `hello_baseline_truncated` | **M1-DET-04:** a derived baseline cut to 160 lines: exit 1, the stream-length-mismatch report. | +| `hello_baseline_malformed` | **M1-DET-04:** a derived baseline with a bad hash token: exit 2 before any tick is run (the load-time contract, CORE-008). | +| `hello_baseline_missing` | **M1-DET-04:** a missing baseline file: exit 2, the actionable read error. | ## Line budget (PRD §9.4, NFR-13.5) diff --git a/samples/hello/baselines/README.md b/samples/hello/baselines/README.md new file mode 100644 index 0000000..3f71693 --- /dev/null +++ b/samples/hello/baselines/README.md @@ -0,0 +1,72 @@ +# hello scenario — committed per-tick hash baselines (M1-DET-04) + +The reference per-tick `World::stateHash` streams of the hello scenario +(300 ticks, 301 lines: tick 0 = initial state, then one line per +completed tick; each line ` ` with 16 lowercase hex digits — +the [detcheck scenario contract](../../docs/api/detcheck.md)). One file +per SimMath backend (ADR 0002): + +| File | Backend | Config | +|---|---|---| +| `fixed_point_16_16/hash_stream.txt` | `fixed_point_16_16` (Q16.16, `SimMathFpx16` — the default) | canonical sample config (60 Hz, budget 8, churn 256, seed `0x1F055EED`) | +| `float_pinned_32/hash_stream.txt` | `float_pinned_32` (IEEE float, `SimMathFp32` — same-build/same-ISA scope) | identical config, backend field differs (the replay identity, ADR 0002) | + +Both streams start on the same tick-0 hash (`1d0bee038913bfc2`): the +initial state (one entity, `PlayerPos` at the box center, zero PRNG +draws) is byte-identical across backends, and the per-tick hashes +diverge from tick 1 as the positions differ. + +## Reference build + +- **Tree:** the canonical `Debug` g++ tree (`build/`): + `cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++` + (the canonical command in [building.md](../../docs/getting-started/building.md)). +- **Compiler:** g++ (GCC) 16.2.1; engine policy + the SimMath pinned set + (ADR 0002: `-ffp-contract=off -fno-associative-math`), no sanitizers. +- **Generated (M1-DET-04):** 2026-09-17 on AMD Ryzen 9 7950X3D / + CachyOS x86_64. + +`fixed_point_16_16` is bit-exact **by the C++20 standard** (signed +integer arithmetic, no compiler freedom): any conforming build must +reproduce it — the CI matrix proves it across g++/clang++/ASan/Release +and, per P0 OS job, on macOS/Windows. `float_pinned_32` is +same-build/same-ISA by scope (ADR 0002); the scenario's op surface is +single-rounding IEEE add/sub with no FMA and no reassociation (the +pinned flags), so the stream is expected platform-invariant — the CI +determinism matrix ([docs/benchmarks/determinism-matrix.md](../../docs/benchmarks/determinism-matrix.md)) +is the evidence; a desynced pair is declared unsupported for +`float_pinned_32` (ADR 0002), never re-baselined silently. + +## How a build checks a baseline + +`hello --expect BASELINE` (the scenario-side equivalent of +`laige-replay --expect` — the scenario's own binary carries the check +because a game log cannot be replayed by `laige-replay`; see +[detcheck.md](../../docs/api/detcheck.md)): exit 0 on identity, exit 1 +with the first-divergence report, exit 2 on a baseline read/contract +error. The P0 OS jobs' ctest runs both backends against these baselines +on every platform (tests `hello_baseline_fpx` / `hello_baseline_fp32`); +the merge `detcheck` job adds the cross-compiler / sanitizer / Release +pairs. + +## Regeneration policy + +Regenerate only with a **deliberate, documented change** to the scenario +or its config (a new system, a constant change, a seed or tick-rate +change, an engine state-hash fix) — never to make a red CI green (that +would mask a real desync; CORE-008). Procedure: rebuild the canonical +Debug g++ tree, then + +```console +$ ./samples/hello/bin/hello \ + > samples/hello/baselines/fixed_point_16_16/hash_stream.txt +$ ./samples/hello/bin/hello-fp32 \ + > samples/hello/baselines/float_pinned_32/hash_stream.txt +``` + +and verify the cross-compiler identity before committing (rebuild the +clang++ tree, re-run both binaries, `cmp` the streams against the new +baselines) — the same evidence the CI matrix produces. Every +regeneration is recorded in the +[determinism-matrix report](../../docs/benchmarks/determinism-matrix.md) +with the reason and the commit that changed the stream. diff --git a/samples/hello/baselines/fixed_point_16_16/hash_stream.txt b/samples/hello/baselines/fixed_point_16_16/hash_stream.txt new file mode 100644 index 0000000..4438f7f --- /dev/null +++ b/samples/hello/baselines/fixed_point_16_16/hash_stream.txt @@ -0,0 +1,301 @@ +0 1d0bee038913bfc2 +1 34ad0d068f9da541 +2 ae74b303394975e8 +3 accc1baf6c5c620f +4 c839fa98bb576846 +5 86f2063340df2465 +6 e91d52941c592cdc +7 4667195f9467cbc3 +8 038223e8f7d2597a +9 4c9df4d6d4a01279 +10 73d59d614b5f51e0 +11 e51a491fb0bd2fa7 +12 3f1906bc138e231e +13 d6602b6c945106bd +14 180f7df2dc48ff94 +15 1e5002bc6848691b +16 2959e3050a6828e2 +17 729f4bdf464133c1 +18 6941c167ae7a8654 +19 2d497de5bd2e6cb3 +20 6d91f1dfb8d8b47e +21 287208511dc06345 +22 f7e3f0fd6335e0b0 +23 ea89ea044819cdd7 +24 6b1ab94a0052af8a +25 17e0269a52aca2c9 +26 4625e98c6da28a3c +27 e9ff2cd327eb125b +28 1f3ae04283340cc6 +29 393a3bf0cf2ed84d +30 b45d868a8da83c78 +31 3c83fabada3de61f +32 e201b99c31c68c62 +33 dfb77561b972c4e1 +34 f29bd0721a73b788 +35 38689c0cbf76452f +36 ea6565f48f2fcce6 +37 35e8d774af22da85 +38 dd8ba029df9ae47c +39 e2612f08efe0d563 +40 3f7bba9da90d9f1a +41 7ddd7a5b7c763099 +42 c8b4ad7639454b00 +43 0384c4354c1ec747 +44 161a4838151c7d3e +45 9da98b1980d3e2dd +46 7ca5c6167dae6ab4 +47 88bc455636488b3b +48 79326b6d3848b282 +49 385a2abb9b100d61 +50 92b8fb887893fd74 +51 6113cd25bc595e53 +52 631d052d5ce3ad9e +53 bec96eabb74cf965 +54 37af45bc924373d0 +55 d2e39e1c8aeff677 +56 c04e285cf4aa03aa +57 b47605905860ee69 +58 52b3dcc7d88194dc +59 6513aaadef4a847b +60 38df61c7d8d73966 +61 6987caa5d953b4ed +62 d25fb36b5586b518 +63 5c610ff558bd163f +64 fa13992d2b6db682 +65 74e98e4f2163b501 +66 0a1409658c6885a8 +67 19664d89e9ca7dcf +68 dc8a12efd6301b06 +69 f0b111fe7d147125 +70 28b701c121c9699c +71 eead85942974f483 +72 9d79c46c9ad9c33a +73 39058d7a549c5639 +74 aa19fc7d62b911a0 +75 9d36dfb15f6b5b67 +76 c258dbea7da0dede +77 df1c7c68e3e82b7d +78 57ef56dd41e81954 +79 87d64760511ed7db +80 39a96412137f1ba2 +81 089e00d976aad381 +82 b44418a9e840c514 +83 b326e819feb5ef73 +84 8695db4cb1cce63e +85 63b7baf645c1bc05 +86 149f5fcb3b63ae70 +87 253bf826bf642b97 +88 821b58bdcf8e6e4a +89 9f596d5e0a636989 +90 6395e13ef4da00fc +91 c4a1aad70b21261b +92 d48c60c155079586 +93 cb98775de768670d +94 cf223dd7b753e538 +95 77d1ceb94dadc1df +96 55e2c3b16c3dd922 +97 4a00b3fc11213ca1 +98 c93fc4a0dec40148 +99 11bf5c3b954321ef +100 e97f7771b03bf5a6 +101 fa40a6ad8a52a645 +102 c6dd2660410d7a3c +103 bc68abcfea639423 +104 3320830772eff6da +105 806059257ee55d59 +106 ad457146e65b3dc0 +107 5752fcc5f2886b07 +108 0c99931e6fae8ffe +109 275f32a3892ed19d +110 38f2c7b0af730c74 +111 2588283f109719fb +112 70c105fe8b1ff442 +113 a565df8ef14b6d21 +114 afc9acccd4800a34 +115 982fcd18f9125013 +116 31aafad3c15a095e +117 6fd4287acfdb5c25 +118 f9a748eaf72efd90 +119 0f80ae5d7c217737 +120 11ce936d33c5766a +121 ebe2d9e06598e729 +122 197bc05024dd879c +123 de1c389819c81a3b +124 8e63e1b349e47e26 +125 45ebc2839f35fcad +126 01f90dfe6b968ad8 +127 28837127161719ff +128 3b01dfbdbbca3542 +129 190c4c58fd65c2c1 +130 f9850c8a1799f768 +131 782279645cce218f +132 a9a80c09547a2dc6 +133 dffb2b28a70e79e5 +134 afe2e56ad8788c5c +135 77f54b0a9504e943 +136 ec9812d9562ed0fa +137 f4f40659d358f1f9 +138 84a75f8626ba2d60 +139 c5bf9eb64497ed27 +140 6c9b5906f97cfc9e +141 809fb756dd714e3d +142 35efdc06b0c7e914 +143 0714b518fa46ca9b +144 e726a4ee0557d062 +145 9a100618c6a6f141 +146 2d8bce4b08fea3d4 +147 894e708cfcefd633 +148 de8d367964bd55fe +149 1fde76446759d4c5 +150 2ee8343b05783c30 +151 3c1174f18921ff57 +152 b8c032ffe7b77f0a +153 ffb8898cea4b8e49 +154 461ba8618e4717bc +155 715c83d2e9ed65db +156 61b28f53d847e046 +157 05ca1e9689d74fcd +158 f78cb59784ca13f8 +159 d95114cbc03e139f +160 c33cba6cde8ca3e2 +161 ae6928ddf5aed661 +162 016e030c5cde4708 +163 41e3c6c56490e6af +164 7ad8c4414d8ed666 +165 82744bea1a7b2205 +166 10b0ca20194633fc +167 c0f908903959b8e3 +168 cc6a5a193bcd149a +169 1062e009b32e5419 +170 9a72e0c4817ba480 +171 eb68d4e447a7b6c7 +172 5fdb5c161b8068be +173 16e4c4d5d1fcf85d +174 a2f16b8c73863c34 +175 88926303b953d0bb +176 be7dd03b9704a002 +177 8d8e37c14d0a94e1 +178 12cbab22f9dd9cf4 +179 4db4582f3e3c13d3 +180 767c735651af671e +181 882673ba73c5eee5 +182 57a40d46eda08b50 +183 7b163d6c29b89ff7 +184 37f27c138f0ad52a +185 5b476cb6e46ee1e9 +186 2e038d4d84d82e5c +187 57822dff05f42bfb +188 b43bfd0708a60ae6 +189 88620d6f9d77606d +190 6b0126cbcfca0898 +191 736dc04f04feb3bf +192 0a4a99c282cc8a02 +193 3ff293ede6ec6a81 +194 6927f95990d15f28 +195 cadee87f0284454f +196 17adde8449f4a086 +197 43b3967a62b19ea5 +198 d8bf4cf72b32131c +199 15ed16e2e4389203 +200 5f6bf22da37192ba +201 081836b4d99fa7b9 +202 8a7799be09ed7920 +203 374d9d20d1f078e7 +204 12211c97e402ce5e +205 5c54f439785104fd +206 514dc7f9a3c950d4 +207 061b34f41aea915b +208 1d755f3ed5a75922 +209 9cba379cee453d01 +210 a233b42201efb694 +211 33101e516287a0f3 +212 056b5363bf8debbe +213 b634093a821f3385 +214 6f40deb1a7ea1bf0 +215 38057903c5dde917 +216 e53940ca792909ca +217 bfffa079e2a7e909 +218 d5a62efac5443e7c +219 5d9cc3a45dfbc79b +220 7e80933655eb3d06 +221 9ecf47ef0b12508d +222 bd3ef55a0cd818b8 +223 149d1c79fdd81f5f +224 4c5e071ea0b7e0a2 +225 9446a95604fe8021 +226 c844ca27b8c86ac8 +227 e3938856133ff96f +228 43015ad915509726 +229 26c178d9db4931c5 +230 567f051c0b6867bc +231 39e613caee708fa3 +232 6a8bdcec0e839e5a +233 b9dbd60d61210ad9 +234 8d88158fe8b59340 +235 06c9b264fba8e887 +236 05944a567ef2f57e +237 38dab93043a76f1d +238 877f59aa540177f4 +239 56ff656fe8240b7b +240 d9bd100800167fc2 +241 005561e70783b6a1 +242 ca5fc7cab962cbb4 +243 c098137979f76d93 +244 05c87aa8492eb2de +245 f45cca979233bda5 +246 55520acd50c77910 +247 8f0dc451acc4d2b7 +248 1870ceb07e44a9ea +249 12b0562264c27ca9 +250 7fe826ef51823f1c +251 6d9e762c4689bfbb +252 3a8442df709de9a6 +253 b1e3836040226a2d +254 53ebf42418146858 +255 8bc679fc289c857f +256 1453aa66ddc8d90f +257 5537a432c4bfbba8 +258 9c349bbe010a1c41 +259 453733fa63c4ec82 +260 adeea81705d442c3 +261 73da9c8e5a209f9c +262 ee7994eab24b9b65 +263 27adadbd0e875106 +264 4ca1d7d72229a6a7 +265 f53d974a9b1047a0 +266 b425838e460c8979 +267 e89d5f39d330f93a +268 85d79173d9b4e01b +269 a312f1aa7a3f4f54 +270 3de7ba2405bd7dbd +271 0d7c76b7b5f814de +272 c3f7040fd4a9c7ef +273 1045e4a0d1668fe8 +274 5a143cc5094d223d +275 a07e53cf96454dd6 +276 cf1c7bc51641fd0b +277 d0e838da3f0db2bc +278 13ad79a8e81bdf09 +279 9c3846e1b6f517aa +280 fc4498c993c79d07 +281 80a296ecf9b98ab0 +282 b557a99c30f2c105 +283 3d69928bce2de3de +284 88913bd65d9cf583 +285 b7082da1042f3da4 +286 7513f6260dca88d1 +287 faa7aae54f3eb692 +288 9ff02ac430e2bc2f +289 14635f6e171b3748 +290 473f04192adf3be1 +291 a1065e7ea4950f22 +292 49e8bdc0614d4c63 +293 1200c12d7964b03c +294 9d70662c208f5185 +295 34a3123ee8932ba6 +296 6b0c52ecbd8b3e47 +297 f8690c141eb273c0 +298 e5650912ede2a799 +299 7e441dd4ab472cda +300 f043d40da7b5023b diff --git a/samples/hello/baselines/float_pinned_32/hash_stream.txt b/samples/hello/baselines/float_pinned_32/hash_stream.txt new file mode 100644 index 0000000..2aa8157 --- /dev/null +++ b/samples/hello/baselines/float_pinned_32/hash_stream.txt @@ -0,0 +1,301 @@ +0 1d0bee038913bfc2 +1 989290fee4e47de1 +2 91d9fa6ec346d648 +3 104bdc5183ea923f +4 38027572dff37c06 +5 2f6696e26822c8b5 +6 a69b12b34017b67c +7 7c0cf1df74a11bf3 +8 f41e33b212d8522a +9 6fc56834ab549df9 +10 16b8619c3a26b630 +11 5334b1422a8dccc7 +12 aac4a73b5fa261ce +13 f6ac2b048f21619d +14 f1f95fbc07bd8d64 +15 146ff24c33673e7b +16 f5a3055eebb3b442 +17 3624c672b949b0f1 +18 84b8c67eb7596d08 +19 1aa0742e9e0e3c3f +20 b7f7ef3645a84a06 +21 15b8d9277f4cc935 +22 4481d84c6c66ff7c +23 526549714e1f8593 +24 bd38d0132fc7de7a +25 d7a6b89ea31f9619 +26 5a170c5bf8ef6550 +27 b42711921fe54ea7 +28 31fe5aa751d67c2e +29 d946a67f593bcc1d +30 a2df058e52aeb804 +31 3a041a49893058ab +32 e201b99c31c68c62 +33 7084dd10f41d8781 +34 770ee8d11dcc4ae8 +35 6cbf727f04617b5f +36 8f466c55db4a0c26 +37 65b2e55bf11cde55 +38 d9ac2dde05e56d1c +39 e507956e74326f13 +40 797b42b5fecc684a +41 f234aa3fcda5e499 +42 cb5e498dea41a250 +43 33a92184eadd0867 +44 c0e30207cce6ad6e +45 72f195879566c4bd +46 3dd0e42b6b7b9704 +47 a8968ac6886eb99b +48 ed98bcc902e332e2 +49 d3f4298ad92ac291 +50 e9fb16d25cae9928 +51 77140a5c1e85255f +52 c4ed53b81fb424a6 +53 6dfd2c9804d0f2d5 +54 8d3593ea1ff7989c +55 62ce1dee9218b5b3 +56 f93266c7e103241a +57 5402eec817bc3539 +58 9d5d82cb10db85f0 +59 17cda6e3ae93c147 +60 afaa8def259e0bce +61 b6b7d8fa30335e3d +62 c43b2b98c53c18a4 +63 df561db9379c504b +64 fa13992d2b6db682 +65 7296ac54310005a1 +66 c703fb3174546408 +67 fbd51ebf7e0bcbff +68 5d4cefef9566acc6 +69 b5a862b66df37375 +70 f7298a19332cb23c +71 3f41155d8be9f2b3 +72 222777a946de92ea +73 3081f815b3b63eb9 +74 dfdb794d705187f0 +75 5e9ced2d03ea5687 +76 4acf06ff9a3d458e +77 68e1124c6d6bdb5d +78 2ba1455f6db6ca24 +79 6e119efa211d9e3b +80 d4e460f98d5ab102 +81 ff90a91d65b647b1 +82 860b02e24c3b6dc8 +83 3bc87cc5180ea9ff +84 6a257f7531f86cc6 +85 0b39128474b48ff5 +86 3dcc0018a87e3d3c +87 406bf477d54de253 +88 6729122cad73f03a +89 899d9a2db3dcf6d9 +90 3faf42ec5c0d9510 +91 7c3c49b9a9382267 +92 5c73adb84ef62dee +93 7890edb0cf601cdd +94 114167b6e0cb0cc4 +95 9071d39b8d86856b +96 55e2c3b16c3dd922 +97 188c97168edae941 +98 2515dfad6ed06fa8 +99 94a650bfe0c7421f +100 46a319072fcde9e6 +101 139bb5fef7879915 +102 514724aa88aeecdc +103 e6613cbdbf92a9d3 +104 bd5fb3ebc813800a +105 04b02a9faab9b959 +106 9445b3330461ef10 +107 592da2e102fce227 +108 56c3155eb355742e +109 b94036484f25ad7d +110 d9ccf482891b07c4 +111 cf81d76948a4605b +112 63aa8d68b939e0a2 +113 f0c1519435b53151 +114 f1b580b79afeebe8 +115 1708ada99e0a8d1f +116 3b5637ad2b0d1566 +117 3641140a41ff3395 +118 53cc38cf2cda705c +119 cf9bf7a334545673 +120 fccfd0c7858a23da +121 ae7bf4b258ead5f9 +122 6c10d0669919b8b0 +123 237a9c7a5c9c1e07 +124 87925c10da6f778e +125 fcc0e1888de561fd +126 3b18c9f031d56a64 +127 5736f36afa11470b +128 3b01dfbdbbca3542 +129 d6ff2136158d9f61 +130 90defff59d4b3fc8 +131 97ac6cc48675cfbf +132 9cd0e2727b32a386 +133 969ec5f078d7c835 +134 497b19646e3015fc +135 7180dcf6910a8773 +136 a05fa07d66ae19aa +137 1cdb34d4778d8b79 +138 66a61a223f915bb0 +139 c0bc7eb5f9f6aa47 +140 7acb4fa42e1e3b4e +141 0827b1914999ff1d +142 b5c42beb344f34e4 +143 3864933e26cc19fb +144 f8048d8e7c91f1c2 +145 62644665d0913071 +146 eb43b532f529fa88 +147 c1f247cd55e2c9bf +148 97eafbbeb536f986 +149 b422eeacfa65d6b5 +150 9798456e5b5b9efc +151 d46e889854d29f13 +152 017f7e0f925a2bfa +153 6578ccda2ffc2599 +154 10ddda2484630ed0 +155 c65284c9907b4027 +156 b334a6f14c54adae +157 3d2841411efe4b9d +158 017c2d6e7450ad84 +159 49cab27079e80a2b +160 c33cba6cde8ca3e2 +161 8b2cd8da05198f01 +162 c21f4257fc1ccc68 +163 39ea20900f6024df +164 1b758a564cba27a6 +165 732e15d9ca4545d5 +166 0f54e40fd301809c +167 48165450e2278293 +168 8ccf9133694343ca +169 84ba0fee045e0819 +170 509ef9b598fa17d0 +171 cdbfdef45d6225e7 +172 9ec6d83501306aee +173 1d312171de870c3d +174 413d4cff88e7b684 +175 153db68cc162251b +176 4d3f7c6c30d7ae62 +177 eeffcaebf72d8011 +178 852245f51f01b6a8 +179 642ffb98decd1edf +180 c33e78138092f026 +181 8467bc9c19df2455 +182 1d4c9bf47416f21c +183 2e8ded50c6472f33 +184 e151c54f77f86f9a +185 d979b36bbffb94b9 +186 a2f8a176ed4ccd70 +187 4f7b5121e94070c7 +188 fad5d5e0c8696d4e +189 06b36c26adcf0dbd +190 0209fc13dcf6fc24 +191 a3d56430f51ebdcb +192 0a4a99c282cc8a02 +193 4d21c4f330703921 +194 0d9da6b9fcdba188 +195 2a03c35b89341d7f +196 faf55e0ceeb05646 +197 76717c50a3a9aaf5 +198 5d20c769d60cfdbc +199 8fdc4536ebc8e433 +200 7aaa78c839827e6a +201 b6db2cfdf9a4ba39 +202 d0ec7a4a5c8b9570 +203 049215b0c5bba607 +204 ee3c2fdaa0e85d0e +205 4511b6b2e73492dd +206 e80d93fb1cb7dda4 +207 6de7bca7a428e3bb +208 e504da4df9909082 +209 39c5c40be67f5131 +210 37a29a8a415ea748 +211 4a05de356dedab7f +212 be3208fbae7e2446 +213 e977bba3e6b41775 +214 ad7c9ba8bd8fdabc +215 f8911ea6b1b00dd3 +216 844bfef9ba4195ba +217 55c461d95d6f5659 +218 51862622cd6fa490 +219 47d924c9f87873e7 +220 ddb513ed57cf8f6e +221 4cfce67924bf085d +222 73abdd7384ca7044 +223 d312090b7ddb18eb +224 4c5e071ea0b7e0a2 +225 269677d4241da8c1 +226 8429cfa173394928 +227 813d046005eb2f9f +228 675b9a98f9bfe366 +229 1c0b1c0fcaac5095 +230 ee6216161d53aa5c +231 f3fbd0cb046beb53 +232 7b8a33c1cedc8b8a +233 3e2ba7878cf566d9 +234 d7a2b40a527f3290 +235 cdf42b6d3a13f1a7 +236 097f7f498a8ed9ae +237 3678ae18e38e86fd +238 5c28ad6ae263bb44 +239 a6a8c7f849ca73db +240 f42145bf2c6cda22 +241 7eaca696550904d1 +242 e1951b3275199768 +243 5ed02055c764509f +244 e05d7457825688e6 +245 12e9ac02e382a915 +246 3da343273fe2b3dc +247 dfca511fa3b8f7f3 +248 8f6be9b82553a15a +249 e06d21c950d43b79 +250 21c55c741f7d2030 +251 57ccb290916d1387 +252 e163a05504569d0e +253 f4e58b0679b7a57d +254 5a14552b629ce7e4 +255 166f320eda2d6c8b +256 1453aa66ddc8d90f +257 096dc78d38b1dae8 +258 cae29fd1e1b9c421 +259 fea308366a624d12 +260 69b23df3eeb05e83 +261 cc29ae9c69c9920c +262 b9857f2173306305 +263 e4c3c1d346bb6516 +264 b0c7dfbb80e2eeb7 +265 fadcb4fce5c37000 +266 288720731b96e829 +267 3597f195379e023a +268 0683bf0d1cb49aeb +269 728312a91bd48074 +270 771338d9f0a70aed +271 4d4f76649459a07e +272 8f8c398e4c17300f +273 9af97670af7ebbf8 +274 b4f77066dc36ac61 +275 ff6b86871d2a3b52 +276 c6b4a688a2825983 +277 d13848205c4d7c0c +278 f7c1ef0377a37145 +279 c6754b6966a9dd76 +280 cc7253b402bfec27 +281 d2ea8086da6ce540 +282 39c393aec98aed09 +283 03e1f1f4c8b1d8fa +284 a04c94fdef52dc8b +285 9a84d191e5cf51f4 +286 bcad2cadd7b5cb4d +287 c6cd66dc81907b6e +288 9ff02ac430e2bc2f +289 3289181f20b53988 +290 3fafa13eff7c5ec1 +291 818ccd6d3cfc21b2 +292 45f53fff5619a5a3 +293 7ba6a7e003aa73ac +294 b9e840d5d40ab025 +295 5268e68f118e6836 +296 cf478bbe33bf2957 +297 bc9fa144b66b9e20 +298 f796a0ac7f95cfc9 +299 e72d675ecccf79da +300 7b87da468dd3e48b diff --git a/samples/hello/hello-baseline.cpp b/samples/hello/hello-baseline.cpp new file mode 100644 index 0000000..a972ee7 --- /dev/null +++ b/samples/hello/hello-baseline.cpp @@ -0,0 +1,188 @@ +// hello scenario baseline check — implementation (M1-DET-04). +// +// The comparison mirrors tools/replay/laige-replay.cpp's --expect path +// line-for-line (the bounds, the line split, the strict +// ' ' parse, and the report texts — with "run" in place of +// "replay"): a mismatch is exit 1 with the first-divergence report, a +// baseline read/contract error is exit 2, and the run's own stream is +// the compared artifact (the scenario's stdout, the detcheck scenario +// contract). See hello-baseline.h for the contract. + +#include "hello-baseline.h" + +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include // _SH_DENYNO: plain-fopen sharing for the _fsopen below +#endif + +namespace hello { + +namespace { + +// The baseline bounds (the laige-replay --expect bounds, CORE-005): at +// most kMaxBaselineLines hash lines (the detcheck 65536-tick +// contract) at at most kMaxBaselineLineBytes each; the file read cap +// is the round bound above (laige-replay's kMaxBaselineBytes). +inline constexpr std::size_t kMaxBaselineLines = 65536; +inline constexpr std::size_t kMaxBaselineLineBytes = 64; +inline constexpr std::size_t kMaxBaselineBytes = 8u << 20; + +enum class ReadResult { Ok, Io, TooLarge }; + +// Portable file open (the laige-run / laige-replay precedent: MSVC's +// CRT deprecates plain fopen; _fsopen(_SH_DENYNO) is the plain-fopen +// sharing semantics every other compiler provides). +#if defined(_MSC_VER) +inline std::FILE* openFile(const char* path) { + return ::_fsopen(path, "rb", _SH_DENYNO); +} +#else +inline std::FILE* openFile(const char* path) { return std::fopen(path, "rb"); } +#endif + +// Reads a file into a bounded buffer (the laige-replay readFileBounded +// contract: over the cap is a contract error, never a silent +// truncation). +ReadResult readFileBounded(const std::string& path, std::size_t cap, + std::string* out) { + std::FILE* file = openFile(path.c_str()); + if (file == nullptr) return ReadResult::Io; + out->clear(); + char chunk[8192]; + for (;;) { + const std::size_t n = std::fread(chunk, 1, sizeof(chunk), file); + if (n == 0) { + if (std::ferror(file)) { + std::fclose(file); + return ReadResult::Io; + } + break; // clean EOF + } + out->append(chunk, n); + if (out->size() > cap) { + std::fclose(file); + return ReadResult::TooLarge; + } + } + std::fclose(file); + return ReadResult::Ok; +} + +// One baseline line (the hash-line contract, the detcheck scenario +// form): ' ' — the tick is the exact decimal of the line +// index (no padding or leading zeros), one space, 16 lowercase hex +// digits (laige-replay's parseBaselineLine check, mirrored). +bool parseBaselineLine(const std::string& line, std::size_t lineIndex) { + if (line.size() > kMaxBaselineLineBytes) return false; + const std::string expectedTick = std::to_string(lineIndex); + if (line.compare(0, expectedTick.size(), expectedTick) != 0) return false; + std::size_t pos = expectedTick.size(); + if (pos >= line.size() || line[pos] != ' ') return false; + ++pos; + if (line.size() - pos != 16) return false; + for (std::size_t i = 0; i < 16; ++i) { + const char c = line[pos + i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false; + } + return true; +} + +} // namespace + +BaselineCheck::BaselineCheck(std::string_view path) { + if (path.empty()) return; // a plain run: no check (inactive) + std::string file; + const ReadResult read = readFileBounded(std::string(path), + kMaxBaselineBytes, &file); + if (read == ReadResult::Io) { + error_ = "cannot read the baseline file '" + std::string(path) + + "' (io error)"; + return; + } + if (read == ReadResult::TooLarge) { + error_ = "baseline file exceeds the " + std::to_string(kMaxBaselineBytes) + + "-byte read cap (the laige-replay --expect bound)"; + return; + } + // Split into lines (one separator per line; an optional trailing + // newline and an optional trailing \r are tolerated — the detcheck + // contract, the laige-replay split mirrored line-for-line). + std::vector lines; + std::size_t start = 0; + for (;;) { + const std::size_t nl = file.find('\n', start); + if (nl == std::string::npos) { + lines.push_back(file.substr(start)); + break; + } + std::string line = file.substr(start, nl - start); + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(std::move(line)); + start = nl + 1; + if (start >= file.size()) break; // trailing newline + } + if (!lines.empty() && lines.back().empty() && !file.empty() && + file.back() == '\n') { + lines.pop_back(); // the optional trailing newline + } + if (lines.size() > kMaxBaselineLines) { + error_ = "baseline has " + std::to_string(lines.size()) + + " lines (the contract bound is " + + std::to_string(kMaxBaselineLines) + ")"; + return; + } + for (std::size_t i = 0; i < lines.size(); ++i) { + if (!parseBaselineLine(lines[i], i)) { + error_ = "malformed baseline line " + std::to_string(i) + ": '" + + lines[i] + + "' (the contract is ' ': the line index, one " + "space, 16 lowercase hex digits)"; + return; + } + } + baseline_ = std::move(lines); +} + +void BaselineCheck::emit(std::uint64_t tick, std::uint64_t hash) { + char line[40]; + std::snprintf(line, sizeof(line), "%llu %016llx", + static_cast(tick), + static_cast(hash)); + std::printf("%s\n", line); + if (baseline_.empty() || firstDiff_ != std::uint64_t(-1)) return; + if (tick >= baseline_.size()) { + firstDiff_ = baseline_.size(); // the first extra line + lineA_ = ""; + lineB_ = line; + return; + } + if (std::strcmp(line, baseline_[tick].c_str()) != 0) { + firstDiff_ = tick; + lineA_ = baseline_[tick]; + lineB_ = line; + } +} + +std::string BaselineCheck::failText(std::uint64_t emitted) const { + if (baseline_.empty()) return ""; // no check (inactive or load failed) + if (firstDiff_ != std::uint64_t(-1) && firstDiff_ < baseline_.size()) { + return "hash mismatch at tick " + std::to_string(firstDiff_) + + " (first divergence)\n baseline: " + lineA_ + + "\n run: " + lineB_; + } + if (emitted != baseline_.size()) { + const std::uint64_t at = std::min(emitted, baseline_.size()); + return "baseline stream length mismatch: the baseline has " + + std::to_string(baseline_.size()) + " lines, the run produces " + + std::to_string(emitted) + " (first missing/extra at tick " + + std::to_string(at) + ")"; + } + return ""; +} + +} // namespace hello diff --git a/samples/hello/hello-baseline.h b/samples/hello/hello-baseline.h new file mode 100644 index 0000000..46deee9 --- /dev/null +++ b/samples/hello/hello-baseline.h @@ -0,0 +1,72 @@ +// hello scenario baseline check (M1-DET-04). +// +// The scenario's committed per-tick hash stream (the reference build — +// the canonical Debug g++; the samples/hello/baselines/ README) is +// compared line-by-line against a run's stdout stream with the +// `laige-replay --expect` contract (tools/replay/laige-replay.cpp): +// +// - exit 0: every emitted line matches the baseline +// - exit 1: a per-tick hash mismatch — the first divergence is +// reported (laige-replay report format, "first divergence") +// - exit 2: a baseline read / contract error (the loud CORE-008 +// failure before any tick is run) +// +// The scenario's own binary carries the check because a game +// scenario's log cannot be replayed by `laige-replay` (it registers +// only the engine's built-ins — the replay identity, ADR 0002); the +// split is documented in docs/api/replay.md. This file is the CI +// machinery for the M1-DET-04 determinism matrix — it is deliberately +// a separate translation unit so the template game (hello.cpp) stays +// inside the PRD §9.4 line budget. + +#pragma once + +#include +#include +#include +#include +#include + +namespace hello { + +// One loaded baseline plus the comparison state of the run that emits +// into it. Cold path (one load, one compare per emitted line); no +// allocation after the load (the compare records first-divergence +// strings only once). +class BaselineCheck { + public: + // Load the baseline from `path` (empty path: a plain run, no check — + // the object stays inactive). On a read or contract failure, + // errorText() is non-empty and the object is inactive; the caller + // reports and exits 2. Bounds: the laige-replay --expect bounds + // (kMaxBaselineBytes read cap, 64 bytes/line, 65536 lines — the + // detcheck scenario contract). + explicit BaselineCheck(std::string_view path); + + // Print one ` ` line to stdout (the detcheck scenario + // contract — 16 lowercase hex digits, tick 0 first, step 1) and, + // when active, compare it against the baseline's line at the same + // tick (O(1); the first divergence is recorded, the run continues — + // the laige-replay --expect semantics). + void emit(std::uint64_t tick, std::uint64_t hash); + + // The failure report text (laige-replay --expect format) when the + // run diverged from the baseline; "" when it matched (or no check + // was active). `emitted` is the number of lines emitted (the tick-0 + // line plus one per completed tick). O(1); no side effects. + [[nodiscard]] std::string failText(std::uint64_t emitted) const; + + // Non-empty when the baseline failed to load (the caller prints + // "hello: baseline: " and exits 2). "" when inactive or + // loaded. No side effects. + [[nodiscard]] const std::string& errorText() const { return error_; } + + private: + std::string error_{}; // non-empty: a load/contract failure + std::vector baseline_{}; // the loaded lines (empty: inactive) + std::uint64_t firstDiff_ = std::uint64_t(-1); // npos: no divergence yet + std::string lineA_{}; // the baseline's line at firstDiff_ + std::string lineB_{}; // the run's line at firstDiff_ +}; + +} // namespace hello diff --git a/samples/hello/hello-fp32.cpp b/samples/hello/hello-fp32.cpp new file mode 100644 index 0000000..d7c1154 --- /dev/null +++ b/samples/hello/hello-fp32.cpp @@ -0,0 +1,27 @@ +// The float_pinned_32 build variant of the hello scenario +// (M1-DET-04). +// +// The SAME game source with the SimMath backend swapped by the build +// (ADR 0002: one op surface, two backends — the game code is written +// once against the interface). The canonical variant (hello.cpp, no +// defines) runs fixed_point_16_16; this TU runs float_pinned_32. The +// embedded config (hello.cpp) then carries the matching SimMathBackend, +// so the replay identity's math_backend_id (ADR 0002) records the +// backend the run was actually computed in. +// +// Its committed baseline: samples/hello/baselines/float_pinned_32/ +// (generated the same way as the canonical baseline — the canonical +// Debug g++ build; see the baselines/README.md). +// +// The CI role: every P0 job compares this variant against its baseline +// (tests/sample `hello_baseline_fp32`), and the CI `detcheck` job +// pairs it with its other-compiler / sanitizer / Release builds +// (docs/benchmarks/determinism-matrix.md). + +#define LAIGE_HELLO_BACKEND laige::sim::SimMathFp32 +#define LAIGE_HELLO_BACKEND_ID laige::SimMathBackend::FloatPinned32 +// The scenario's constants are small exact integers (|v| ≤ 32): the +// int → float conversion is exact for them (2^24 exact-int mantissa). +#define LAIGE_HELLO_FROM_INT32(V) static_cast(V) + +#include "hello.cpp" diff --git a/samples/hello/hello.cpp b/samples/hello/hello.cpp index a7f8ea0..52a86bd 100644 --- a/samples/hello/hello.cpp +++ b/samples/hello/hello.cpp @@ -6,7 +6,8 @@ // the registration order (part of the replay identity — ADR 0002), and // the per-tick state-hash stream (docs/api/detcheck.md). // -// Usage: hello [--replay LOG] | hello --log LOG +// Usage: hello [--replay LOG] | hello --log LOG | hello --expect BASELINE +// (the modes are composable: --expect applies to either run form) // stdout N+1 ' ' lines (tick 0 = initial state, then one // per completed tick; 16 lowercase hex) — nothing else // stderr the one-line summary + diagnostics @@ -18,6 +19,22 @@ // values): the M1 template keeps the CLI minimal (CORE-004); the // engine's CLI (laige-run) exercises the declarative config surface. // +// --expect BASELINE (M1-DET-04): compare the run's per-tick hash stream +// against the committed baseline of the reference build (canonical +// Debug g++; the samples/hello/baselines/ README) with the laige-replay +// --expect contract: 0 match, 1 first-divergence report, 2 baseline +// read/contract error. The scenario's own binary carries the check — +// a game log cannot be replayed by laige-replay (different +// registrations — the replay identity, ADR 0002). +// +// The SimMath backend (ADR 0002): fixed_point_16_16 by default; the +// hello-fp32 build variant (hello-fp32.cpp) swaps the three +// LAIGE_HELLO_BACKEND macros to run the same game on float_pinned_32 — +// one op surface, two backends, the game code written once (M1-DET-04). +// LAIGE_HELLO_FROM_INT32 is the exact small-int → Scalar construction +// for the active backend (the Scalars differ: fpx16_16 has fromInt32, +// float has none). +// // The tick loop uses the engine's own tick primitives (one beginFrame() // + one runSystems() per tick — the runReplay shape, laige/sim/ // replay.h): the detcheck contract needs a per-completed-tick state hash @@ -37,8 +54,14 @@ #include "laige/result.h" #include "laige/sim/engine.h" // EngineConfig #include "laige/sim/replay.h" // ReplayRecorder, loadReplay, identity +#include "hello-baseline.h" // M1-DET-04: the --expect baseline check -using M = laige::sim::SimMathFpx16; // the ADR 0002 default backend +#ifndef LAIGE_HELLO_BACKEND +#define LAIGE_HELLO_BACKEND laige::sim::SimMathFpx16 +#define LAIGE_HELLO_BACKEND_ID laige::SimMathBackend::FixedPoint16_16 +#define LAIGE_HELLO_FROM_INT32(V) laige::fpx16_16::fromInt32(V) +#endif +using M = LAIGE_HELLO_BACKEND; // The game's only component (FR-1.2): the player's 2D position (PRD §4: // the simulation is 2D). Marked a component and determinism-safe (G-R8): @@ -55,10 +78,10 @@ namespace { // unit per tick along the diagonal and wraps in a 32-unit box — a full // crossing is 32 ticks (~0.53 s at the canonical 60 Hz). constexpr std::uint32_t kScenarioTicks = 300; // the M1 CI scenario -constexpr M::Scalar kVelocity = M::Scalar::fromInt32(1); // 1 unit/tick -constexpr M::Scalar kBoxMin = M::Scalar::fromInt32(-16); -constexpr M::Scalar kBoxMax = M::Scalar::fromInt32(16); -constexpr M::Scalar kBoxSize = M::Scalar::sub(kBoxMax, kBoxMin); // 32 units +constexpr M::Scalar kVelocity = LAIGE_HELLO_FROM_INT32(1); // 1 unit/tick +constexpr M::Scalar kBoxMin = LAIGE_HELLO_FROM_INT32(-16); +constexpr M::Scalar kBoxMax = LAIGE_HELLO_FROM_INT32(16); +constexpr M::Scalar kBoxSize = LAIGE_HELLO_FROM_INT32(32); // 32 units // The game's only system (FR-1.3: plain function, 1 ms budget, declared // Write on PlayerPos): advance each axis by the constant velocity and @@ -71,11 +94,7 @@ void MovePlayer(laige::World&, laige::SystemContext& ctx) { // facade in release and asserts in debug — the query guard's // documented behavior (query.h), so the Status is cast away here. static_cast(ctx.each([](laige::Entity, PlayerPos& p) { - auto stepAxis = [](M::Scalar& v) { - v = M::add(v, kVelocity); - if (v >= kBoxMax) v = M::sub(v, kBoxSize); - if (v < kBoxMin) v = M::add(v, kBoxSize); - }; + auto stepAxis = [](M::Scalar& v) { v = M::add(v, kVelocity); if (v >= kBoxMax) v = M::sub(v, kBoxSize); if (v < kBoxMin) v = M::add(v, kBoxSize); }; stepAxis(p.pos.x); stepAxis(p.pos.y); }, laige::Write{})); @@ -88,38 +107,36 @@ void report(const char* stage, laige::ErrorCode code) { } // namespace int main(int argc, char** argv) { - std::string replayPath, logPath; + std::string replayPath, logPath, expectPath; std::uint32_t ticks = kScenarioTicks; if (argc % 2 == 0) { std::fprintf(stderr, "hello: an option is missing its value\n"); return 2; } for (int i = 1; i < argc; i += 2) { const std::string_view a = argv[i], v = argv[i + 1]; if (a == "--replay") replayPath = v; else if (a == "--log") logPath = v; + else if (a == "--expect") expectPath = v; else { std::fprintf(stderr, "hello: unknown option '%.*s' (see samples/hello/README.md)\n", static_cast(a.size()), a.data()); return 2; } } if (!logPath.empty() && !replayPath.empty()) { std::fprintf(stderr, "hello: --log and --replay are exclusive\n"); return 2; } - // 1. The canonical config (identical to config.json): 60 Hz, scene + // 1. The canonical config (identical to config.json, except the + // hello-fp32 variant's LAIGE_HELLO_BACKEND_ID): 60 Hz, scene // budget 8, the engine churn default, the house seed - // (docs/testing.md), deterministic fpx16_16 — the only backend this - // template supports (ADR 0002). - const laige::EngineConfig config{60, 8, 256, 0x1F055EED, {true, laige::SimMathBackend::FixedPoint16_16}}; + // (docs/testing.md), deterministic — the backend is part of the + // replay identity (ADR 0002). + const laige::EngineConfig config{60, 8, 256, 0x1F055EED, {true, LAIGE_HELLO_BACKEND_ID}}; // 2. World + the game's registration (the setup phase; the registration // order is part of the replay identity — ADR 0002), then the player // entity at the box center (the initial state the tick-0 line covers) // and the pre-run system schedule (M1-SYS-02). - laige::World::Options o{config.entityCapacity, config.churnPerFrameBudget, config.seed, config.determinism.enabled}; - auto wr = laige::World::create(o); - if (wr.isError()) { report("world", wr.error()); return 2; } + const laige::World::Options o{config.entityCapacity, config.churnPerFrameBudget, config.seed, config.determinism.enabled}; + auto wr = laige::World::create(o); if (wr.isError()) { report("world", wr.error()); return 2; } laige::World world = std::move(wr).takeValue(); const laige::Status setup = [&world] { - const auto c = world.registerComponent(); - if (c.isError()) return laige::Status(c.error()); - const auto s = world.registerSystem(MovePlayer_Def, laige::Io{}); - if (s.isError()) return laige::Status(s.error()); - const auto e = world.create(); - if (e.isError()) return laige::Status(e.error()); + const auto c = world.registerComponent(); if (c.isError()) return laige::Status(c.error()); + const auto s = world.registerSystem(MovePlayer_Def, laige::Io{}); if (s.isError()) return laige::Status(s.error()); + const auto e = world.create(); if (e.isError()) return laige::Status(e.error()); return world.addComponent(e.value(), PlayerPos{}); }(); if (setup.isError()) { report("world", setup.error()); return 2; } @@ -131,8 +148,7 @@ int main(int argc, char** argv) { // replay, never a silent divergence). The log's frame count defines // the run length. if (!logPath.empty()) { - const auto lg = laige::loadReplay(logPath); - if (lg.isError()) { report("replay", lg.error()); return 2; } + const auto lg = laige::loadReplay(logPath); if (lg.isError()) { report("replay", lg.error()); return 2; } if (!laige::replayIdentityDiff(lg.value(), world, config).empty()) { report("replay: identity mismatch", laige::ErrorCode::InvalidArgument); return 2; } ticks = static_cast(lg.value().frames.size()); } @@ -145,31 +161,42 @@ int main(int argc, char** argv) { std::fprintf(stderr, "hello: replay: recording is a debug-build feature (NDEBUG)\n"); return 2; #else - auto r = laige::ReplayRecorder::create(laige::makeReplayIdentity(world, config), replayPath, laige::kDefaultReplaySizeLimit); - if (r.isError()) { report("replay", r.error()); return 2; } + auto r = laige::ReplayRecorder::create(laige::makeReplayIdentity(world, config), replayPath, laige::kDefaultReplaySizeLimit); if (r.isError()) { report("replay", r.error()); return 2; } recorder = std::make_unique(std::move(r).takeValue()); #endif } - // 5. The tick loop: the tick-0 line (initial state), then one line per - // completed tick — stdout carries nothing else. - std::printf("0 %016llx\n", static_cast(world.stateHash(0))); - laige::Status runStatus{}; - std::uint64_t completed = 0; + // 5. The baseline check (--expect, M1-DET-04): the committed per-tick + // hash stream of the reference build (canonical Debug g++; the + // samples/hello/baselines/ README). A read/contract failure is a + // usage-level error (2) before any tick is run. + hello::BaselineCheck baseline(expectPath); + if (!baseline.errorText().empty()) { std::fprintf(stderr, "hello: baseline: %s\n", baseline.errorText().c_str()); return 2; } + + // 6. The tick loop: the tick-0 line (initial state), then one line per + // completed tick — stdout carries nothing else. Each line is emitted + // through the baseline check (it compares in place when --expect was + // given — the laige-replay --expect semantics). + baseline.emit(0, world.stateHash(0)); + laige::Status runStatus{}; std::uint64_t completed = 0; for (std::uint64_t tick = 1; tick <= ticks; ++tick) { world.beginFrame(); const laige::Status step = world.runSystems(schedule); if (step.isError()) { runStatus = step; break; } if (recorder != nullptr) { const laige::Status f = recorder->writeFrame(tick, nullptr, 0); if (f.isError()) { runStatus = f; break; } } ++completed; - std::printf("%llu %016llx\n", static_cast(tick), static_cast(world.stateHash(tick))); + baseline.emit(tick, world.stateHash(tick)); } - // 6. Ordered teardown (CONC-006): finalize the log only on success (an + // 7. Ordered teardown (CONC-006): finalize the log only on success (an // unfinished recorder's destructor removes its temp file), clear the - // world, shut down the logging facade. + // world, shut down the logging facade. A per-tick identity mismatch + // with the baseline is exit 1 with the first-divergence report + // (laige-replay --expect parity, M1-DET-04). if (recorder != nullptr) runStatus = runStatus.ok() ? recorder->finish() : runStatus; std::fprintf(stderr, "hello %s ticks=%llu status=%s\n", logPath.empty() ? "headless" : "replay", static_cast(completed), runStatus.ok() ? "ok" : laige::errorName(runStatus.error())); + const std::string baselineFail = runStatus.ok() ? baseline.failText(completed + 1) : std::string{}; + if (!baselineFail.empty()) { std::fprintf(stderr, "hello: %s\n", baselineFail.c_str()); return 1; } static_cast(world.clear()); laige::log::Logger::instance().shutdown(); return runStatus.ok() ? 0 : 1; diff --git a/tests/sample/CMakeLists.txt b/tests/sample/CMakeLists.txt index a8c8e75..c3bc523 100644 --- a/tests/sample/CMakeLists.txt +++ b/tests/sample/CMakeLists.txt @@ -1,15 +1,25 @@ -# hello.laige CTest suite (M1-SAMPLE-01). +# hello.laige CTest suite (M1-SAMPLE-01, extended by M1-DET-04). # # The template game is tested end to end against the scenario contract: -# the no-arg 300-tick headless run (exactly what the CI detcheck job's -# "Real scenario" step executes, from the repository root), the -# record -> replay pipeline (a recorded run and its replay are -# byte-identical on stdout — the determinism proof behind M1-DET-04), -# the hash-line contract on stdout (tick 0 first; 16 lowercase hex; one -# line per completed tick), the usage/IO/identity failure paths (exit -# 2, actionable stderr), and the PRD §9.4 game-code line budget -# (< 100 lines of code in hello.cpp — the NFR-13.5 comment lines do not -# count). +# the no-arg 300-tick headless run (exactly what the CI detcheck job +# executes, from the repository root), the record -> replay pipeline +# (a recorded run and its replay are byte-identical on stdout — the +# determinism proof behind M1-DET-04), the hash-line contract on stdout +# (tick 0 first; 16 lowercase hex; one line per completed tick), the +# usage/IO/identity failure paths (exit 2, actionable stderr), and the +# PRD §9.4 game-code line budget (< 100 lines of code in hello.cpp — +# the NFR-13.5 comment lines do not count). +# +# M1-DET-04: the committed per-tick hash baselines (both SimMath +# backends — samples/hello/baselines/) are asserted here in EVERY P0 +# job's ctest: each job's own build of `hello`/`hello-fp32` must +# reproduce the reference build's (canonical Debug g++) stream +# bit-exactly (`hello --expect`, the laige-replay --expect contract — +# 0 match, 1 first divergence, 2 baseline read/contract error). The +# failure fixtures (derived perturbed/truncated/malformed baselines) +# pin the exit-1/exit-2 paths. The cross-compiler / sanitizer / +# Release pairs run in the CI `detcheck` job (docs/benchmarks/ +# determinism-matrix.md). # # Each test is a generated `cmake -P` check script # (expect-sample-result.cmake.in, the tests/replay + tests/detcheck @@ -20,29 +30,54 @@ # directly), so every test is fast under any cadence. set(LAIGE_SAMPLE_TEST_ENV - "SAMPLE_HELLO=$" - "SAMPLE_CONFIG=${CMAKE_SOURCE_DIR}/samples/hello/config.json" + "SAMPLE_HELLO=$;" + "SAMPLE_HELLO_FP32=$;" + "SAMPLE_CONFIG=${CMAKE_SOURCE_DIR}/samples/hello/config.json;" + "SAMPLE_BASELINE_FPX=${CMAKE_SOURCE_DIR}/samples/hello/baselines/fixed_point_16_16/hash_stream.txt;" + "SAMPLE_BASELINE_FP32=${CMAKE_SOURCE_DIR}/samples/hello/baselines/float_pinned_32/hash_stream.txt;" "SAMPLE_OUT_DIR=${CMAKE_CURRENT_BINARY_DIR}") -# laige_sample_test(name [SETUP s] [CMD c] [SECOND_CMD c] [EXPECT_EXIT n] +# The test environment every entry shares (TSan-aware, built ONCE so +# the per-test SAMPLE_DERIVED override appends to the right base — +# CTest's ENVIRONMENT property is REPLACED by a later +# set_tests_properties call). +set(LAIGE_SAMPLE_ENV "${LAIGE_SAMPLE_TEST_ENV}") +if(LAIGE_TSAN) + # The first race report is fatal (NFR-8.2 — the repo convention). + set(LAIGE_SAMPLE_ENV "TSAN_OPTIONS=halt_on_error=1;${LAIGE_SAMPLE_TEST_ENV}") +endif() + +# laige_sample_test(name [CMD c] [SECOND_CMD c] [EXPECT_EXIT n] # [EXPECT_LINES n] [EXPECT_IDENTICAL bool] +# [DERIVE mode] [PERTURB_TICK n] # [CHECK_OUT regex]* [CHECK_ERR regex]*) # Builds one generated check script; CMD/SECOND_CMD may reference -# (expanded at run time from the environment). +# +# (expanded at run time from the environment). DERIVE +# ("hash-perturb" | "truncate" | "malformed") builds a derived copy of +# the committed baseline at $ENV{SAMPLE_DERIVED} (set per test below) +# before the run — the `hello --expect` failure fixtures. function(laige_sample_test name) - set(oneValueArgs SETUP CMD SECOND_CMD - EXPECT_EXIT EXPECT_LINES EXPECT_IDENTICAL) + set(oneValueArgs CMD SECOND_CMD + EXPECT_EXIT EXPECT_LINES EXPECT_IDENTICAL + DERIVE PERTURB_TICK) set(multiValueArgs CHECK_OUT CHECK_ERR) cmake_parse_arguments(ARG "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) # The template's @VAR@ names (configure_file @ONLY scope). set(TEST_NAME "${name}") - set(SETUP_CMD "${ARG_SETUP}") set(CMD "${ARG_CMD}") set(SECOND_CMD "${ARG_SECOND_CMD}") set(EXPECT_EXIT "${ARG_EXPECT_EXIT}") set(EXPECT_LINES "${ARG_EXPECT_LINES}") set(EXPECT_IDENTICAL "${ARG_EXPECT_IDENTICAL}") + set(DERIVE "${ARG_DERIVE}") + set(PERTURB_TICK "${ARG_PERTURB_TICK}") + # The committed baseline the derive step builds its fixtures from + # (a configure-time path — the fpx baseline: the derived copies + # exercise `hello`'s comparison, whose stream is backend-agnostic). + set(DERIVED_SOURCE + "${CMAKE_SOURCE_DIR}/samples/hello/baselines/fixed_point_16_16/hash_stream.txt") set(_checks "") foreach(_frag IN LISTS ARG_CHECK_OUT) string(APPEND _checks " @@ -65,13 +100,8 @@ endif() COMMAND ${CMAKE_COMMAND} -P "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") set_tests_properties(${name} PROPERTIES - ENVIRONMENT "${LAIGE_SAMPLE_TEST_ENV}" + ENVIRONMENT "${LAIGE_SAMPLE_ENV}" TIMEOUT 300) - if(LAIGE_TSAN) - # The first race report is fatal (NFR-8.2 — the repo convention). - set_tests_properties(${name} PROPERTIES - ENVIRONMENT "${LAIGE_SAMPLE_TEST_ENV};TSAN_OPTIONS=halt_on_error=1") - endif() endfunction() # 1) The CI scenario: a no-arg run from any CWD (the canonical config is @@ -147,11 +177,8 @@ add_test(NAME hello_config_valid ${CMAKE_SOURCE_DIR}/samples/hello/config.json --ticks 16) set_tests_properties(hello_config_valid PROPERTIES TIMEOUT 300 - PASS_REGULAR_EXPRESSION "status=ok") -if(LAIGE_TSAN) - set_tests_properties(hello_config_valid - PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") -endif() + ENVIRONMENT "${LAIGE_SAMPLE_ENV}" + PASS_REGULAR_EXPRESSION "status=ok") # 9) The game-code line budget (PRD §9.4): < 100 lines of code in # hello.cpp (non-comment, non-blank lines — the NFR-13.5 comment @@ -161,3 +188,68 @@ add_test(NAME hello_line_budget "${CMAKE_CURRENT_SOURCE_DIR}/check-line-budget.cmake") set_tests_properties(hello_line_budget PROPERTIES ENVIRONMENT "SAMPLE_HELLO_SRC=${CMAKE_SOURCE_DIR}/samples/hello/hello.cpp") + +# --- M1-DET-04: the baseline matrix (every P0 job, both backends) ------- +# The reference-build baselines (canonical Debug g++ — the +# samples/hello/baselines/ README) must be reproduced bit-exactly by +# THIS tree's build of the scenario, on every P0 OS job (and the +# sanitizer lanes' ctest as well). These are the CI form of the +# "run the scenario headless and assert per-tick identity against the +# baseline" half of M1-DET-04 (the `laige-replay --expect` contract on +# the scenario's own stream — the scenario binary carries it because a +# game log cannot be replayed by laige-replay; docs/api/detcheck.md). + +# 10) The fixed_point_16_16 baseline: this build's `hello` vs the +# committed stream. Exit 0, 301 lines, the summary unchanged. +laige_sample_test(hello_baseline_fpx + CMD " --expect " + EXPECT_EXIT 0 + EXPECT_LINES 301 + CHECK_ERR "hello headless ticks=300 status=ok") + +# 11) The float_pinned_32 baseline: this build's `hello-fp32` vs the +# committed stream (the same matrix, the second backend — ADR 0002). +laige_sample_test(hello_baseline_fp32 + CMD " --expect " + EXPECT_EXIT 0 + EXPECT_LINES 301 + CHECK_ERR "hello headless ticks=300 status=ok") + +# 12) The first-divergence path: one flipped hash in a derived copy of +# the baseline -> exit 1, the laige-replay --expect report (the +# tick-150 line, "first divergence"). +laige_sample_test(hello_baseline_mismatch + CMD " --expect " + DERIVE "hash-perturb" PERTURB_TICK 150 + EXPECT_EXIT 1 + CHECK_ERR "hash mismatch at tick 150" "first divergence") +set_tests_properties(hello_baseline_mismatch PROPERTIES + ENVIRONMENT "${LAIGE_SAMPLE_ENV};SAMPLE_DERIVED=${CMAKE_CURRENT_BINARY_DIR}/baseline_mismatch.txt") + +# 13) The stream-length path: a derived baseline truncated to 160 lines +# -> exit 1, the length-mismatch report (first missing at tick 160). +laige_sample_test(hello_baseline_truncated + CMD " --expect " + DERIVE "truncate" PERTURB_TICK 160 + EXPECT_EXIT 1 + CHECK_ERR "length mismatch" "first missing/extra at tick 160") +set_tests_properties(hello_baseline_truncated PROPERTIES + ENVIRONMENT "${LAIGE_SAMPLE_ENV};SAMPLE_DERIVED=${CMAKE_CURRENT_BINARY_DIR}/baseline_truncated.txt") + +# 14) The baseline-contract path: a malformed derived baseline (a hash +# with the wrong digit count) -> exit 2 BEFORE any tick is run +# (the loud CORE-008 load failure, never a silent mismatch). +laige_sample_test(hello_baseline_malformed + CMD " --expect " + DERIVE "malformed" PERTURB_TICK 7 + EXPECT_EXIT 2 + CHECK_ERR "malformed baseline line 7") +set_tests_properties(hello_baseline_malformed PROPERTIES + ENVIRONMENT "${LAIGE_SAMPLE_ENV};SAMPLE_DERIVED=${CMAKE_CURRENT_BINARY_DIR}/baseline_malformed.txt") + +# 15) The missing-baseline path: a baseline path that does not exist -> +# exit 2, the actionable read error. +laige_sample_test(hello_baseline_missing + CMD " --expect /does_not_exist_baseline.txt" + EXPECT_EXIT 2 + CHECK_ERR "hello: baseline:") diff --git a/tests/sample/expect-sample-result.cmake.in b/tests/sample/expect-sample-result.cmake.in index ed39637..8cc6470 100644 --- a/tests/sample/expect-sample-result.cmake.in +++ b/tests/sample/expect-sample-result.cmake.in @@ -1,38 +1,50 @@ # Generated by tests/sample/CMakeLists.txt (@TEST_NAME@) — do not edit. # # The `cmake -P` check-script pattern (tests/replay, tests/detcheck): -# an optional setup step, then the hello command under test, then an -# optional second run. It asserts the exit code, the stdout hash-line -# contract (structurally: CMake's regex engine does not support {n} -# quantifiers and does not interpret `\n` in a pattern — a newline in -# a pattern is written as a real `\n` escape, which CMake turns into a -# newline character), the required stderr fragments, and — when -# requested — the byte-identity of the two runs' stdout (the record -> -# replay determinism check). On failure the FATAL_ERROR carries the -# captured output. Captured stdout/stderr is normalized CRLF -> LF -# (run_cmd below): on Windows a child's text-mode streams are CRLF and -# whether the capture retains the \r is CMake-version-dependent, so -# the script normalizes itself — the per-line contract sees the -# platform-independent LF form (already-normalized content is -# untouched). +# the hello command under test, then an optional second run. It asserts +# the exit code, the stdout hash-line contract (structurally: CMake's +# regex engine does not support {n} quantifiers and does not interpret +# `\n` in a pattern — a newline in a pattern is written as a real `\n` +# escape, which CMake turns into a newline character), the required +# stderr fragments, and — when requested — the byte-identity of the two +# runs' stdout (the record -> replay determinism check). On failure the +# FATAL_ERROR carries the captured output. Captured stdout/stderr is +# normalized CRLF -> LF (run_cmd below): on Windows a child's text-mode +# streams are CRLF and whether the capture retains the \r is +# CMake-version-dependent, so the script normalizes itself — the +# per-line contract sees the platform-independent LF form +# (already-normalized content is untouched). +# +# M1-DET-04 additions: an optional baseline-derivation step builds a +# perturbed/truncated/malformed copy of the committed baseline (the +# `hello --expect` failure fixtures — the same first-divergence and +# length-mismatch paths laige-replay --expect exercises in +# tests/replay). cmake_minimum_required(VERSION 3.16) # --- Inputs (substituted at configure time) ------------------------------ set(_test_name "@TEST_NAME@") -set(_setup_cmd "@SETUP_CMD@") set(_cmd "@CMD@") set(_cmd2 "@SECOND_CMD@") set(_expect_exit "@EXPECT_EXIT@") set(_expect_lines "@EXPECT_LINES@") set(_expect_identical "@EXPECT_IDENTICAL@") +set(_derive "@DERIVE@") +set(_derived_source "@DERIVED_SOURCE@") +set(_perturb_tick "@PERTURB_TICK@") -# --- Command expansion: ---------------------------- +# --- Command expansion: +# ---------------------------- # resolve to the binary/fixture paths from the ctest ENVIRONMENT property # ($ generator expressions cannot reach configure_file). function(expand_vars var) string(REPLACE "" "$ENV{SAMPLE_HELLO}" _s "${${var}}") + string(REPLACE "" "$ENV{SAMPLE_HELLO_FP32}" _s "${_s}") string(REPLACE "" "$ENV{SAMPLE_CONFIG}" _s "${_s}") + string(REPLACE "" "$ENV{SAMPLE_BASELINE_FPX}" _s "${_s}") + string(REPLACE "" "$ENV{SAMPLE_BASELINE_FP32}" _s "${_s}") + string(REPLACE "" "$ENV{SAMPLE_DERIVED}" _s "${_s}") string(REPLACE "" "$ENV{SAMPLE_OUT_DIR}" _s "${_s}") set(${var} "${_s}" PARENT_SCOPE) endfunction() @@ -57,14 +69,46 @@ endfunction() set(_problems "") -# --- The setup step -------------------------------------------------------- -if(NOT _setup_cmd STREQUAL "") - expand_vars(_setup_cmd) - run_cmd("${_setup_cmd}" _setup_out _setup_err _setup_rc) - if(NOT _setup_rc EQUAL 0) - message(FATAL_ERROR "@TEST_NAME@: setup step failed [rc=${_setup_rc}]\n" - "--- setup stdout ---\n${_setup_out}\n" - "--- setup stderr ---\n${_setup_err}") +# --- The optional baseline derivation (the --expect failure fixtures) ---- +# _derive: "" | "hash-perturb" | "truncate" | "malformed"; the derived +# file lands at $ENV{SAMPLE_DERIVED} (per-test ENVIRONMENT), built from +# the committed baseline at $ENV{SAMPLE_DERIVED_SOURCE}. +if(NOT _derive STREQUAL "") + if(NOT _derived_source STREQUAL "") + file(READ "${_derived_source}" _src) + string(REPLACE "\r\n" "\n" _src "${_src}") + if(_src MATCHES "\n$") + # Drop the trailing newline (SUBSTRING length -1 means "to the end"). + string(LENGTH "${_src}" _src_len) + math(EXPR _src_len "${_src_len} - 1") + string(SUBSTRING "${_src}" 0 ${_src_len} _src) + endif() + string(REPLACE "\n" ";" _src_lines "${_src}") + # (REMOVE_AT takes a LIST of indices — no count argument; SUBLIST is + # the range form.) + if(_derive STREQUAL "hash-perturb") + # One hash flipped (the first-divergence fixture). + set(_line "${_perturb_tick} ffffffffffffffff") + list(REMOVE_AT _src_lines ${_perturb_tick}) + list(INSERT _src_lines ${_perturb_tick} "${_line}") + elseif(_derive STREQUAL "truncate") + # Keep only the first _perturb_tick lines (the length-mismatch + # fixture: the run produces more lines than the baseline). + list(SUBLIST _src_lines 0 ${_perturb_tick} _src_lines) + elseif(_derive STREQUAL "malformed") + # A hash token with the wrong digit count (the load-time contract + # fixture: exit 2 before any tick is run). + set(_line "${_perturb_tick} 12345") + list(REMOVE_AT _src_lines ${_perturb_tick}) + list(INSERT _src_lines ${_perturb_tick} "${_line}") + else() + message(FATAL_ERROR "@TEST_NAME@: unknown derive mode '${_derive}'") + endif() + list(JOIN _src_lines "\n" _derived) + file(WRITE "$ENV{SAMPLE_DERIVED}" "${_derived}\n") + else() + message(FATAL_ERROR "@TEST_NAME@: derive mode '${_derive}' needs a " + "DERIVED_SOURCE path") endif() endif() diff --git a/tools/detcheck/CMakeLists.txt b/tools/detcheck/CMakeLists.txt index df82e13..6464108 100644 --- a/tools/detcheck/CMakeLists.txt +++ b/tools/detcheck/CMakeLists.txt @@ -9,8 +9,10 @@ # per-tick state-hash streams (the scenario contract — ` ` # lines, 16 lowercase hex hash digits — is documented in the header # comment of laige-detcheck.cpp and in docs/api/detcheck.md). The -# built-in `synthetic` scenario is the M0 stand-in; M1-DET-04 activates -# the real two-configuration mode (--run-a/--run-b) with M1-SAMPLE-01. +# built-in `synthetic` scenario is the M0 self-check; M1-DET-04 +# activated the real two-configuration mode (--run-a/--run-b + +# --compare-combined) on M1-SAMPLE-01's hello scenario — the CI matrix +# is in docs/benchmarks/determinism-matrix.md. # # Gated with the test suite like tools/fuzz, tools/bench, and tools/api: # a library-only build does not need it.