diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 0692cd9..0675a34 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -256,3 +256,35 @@ jobs: run: cmake --build build --target laige-api-scanner -j - name: Check manifest drift run: ./build/bin/laige-api-scanner --root . --check laige-api.json + + detcheck: + # M0-TOOL-02: runs on every PR (no ci:* condition). The determinism + # checker skeleton (FR-11.5): the built-in synthetic scenario + # self-check runs here so a broken checker lands red before the real + # scenarios exist. The real-scenario comparison (two build + # configurations of M1-SAMPLE-01's hello) is skipped until that + # sample lands; M1-DET-04 activates it (see the job in ci.yml for + # the full note). + name: Determinism check + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + - name: Build + run: cmake --build build -j + - name: Synthetic scenario self-check + run: ./build/bin/laige-detcheck --scenario=synthetic + - name: Real scenario (M1-SAMPLE-01) + # Skipped until M1-SAMPLE-01 lands the hello scenario binary; + # M1-DET-04 replaces this step with the two-configuration + # comparison (see the job in ci.yml). + run: | + if [ -x samples/hello/bin/hello ]; then + ./build/bin/laige-detcheck \ + --run-a=samples/hello/bin/hello \ + --run-b=samples/hello/bin/hello + else + echo "skipped: no real scenario yet (M1-SAMPLE-01)" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80cd461..f416f90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,11 @@ # Cadence (PRD §14: "one per PR, all per merge"): # * this workflow runs on every push to the default branch (master) — # i.e. on merge — and on manual workflow_dispatch: -# ALL eight jobs run (the five P0 OS/variant jobs, the two sanitizer -# lanes (M0-CI-02), and the platform-independent include-graph lint -# with dependency-count metric, M0-CI-03). +# ALL ten jobs run (the five P0 OS/variant jobs, the two sanitizer +# lanes (M0-CI-02), and the three platform-independent tooling jobs: +# the include-graph lint with dependency-count metric (M0-CI-03), +# the public API manifest drift check (M0-TOOL-01), and the +# determinism check (M0-TOOL-02)). # * Pull requests run exactly ONE P0 OS (label-selectable, default # Linux) in the companion workflow .github/workflows/ci-pull.yml. # @@ -26,6 +28,7 @@ # macos-intel macOS Intel, AppleClang (macos-14) # include-lint Include-graph lint + dependency count (M0-CI-03) # api-manifest Public API manifest drift check (M0-TOOL-01) +# detcheck Determinism check (M0-TOOL-02) # # The include-lint job (M0-CI-03; NFR-8.11, NFR-8.13) is platform- # independent — it parses the #include edges of src/** (PRD §10.1 rules: @@ -269,3 +272,42 @@ jobs: run: cmake --build build --target laige-api-scanner -j - name: Check manifest drift run: ./build/bin/laige-api-scanner --root . --check laige-api.json + + detcheck: + # M0-TOOL-02 (FR-11.5; AGENTS ARCH-010, TEST-004): the determinism + # checker skeleton. This job runs the built-in synthetic scenario + # self-check (two in-process runs, per-tick hash comparison — the + # step's Verify clause), so a broken checker lands red here before + # the real scenarios exist. The real-scenario comparison — two build + # configurations of M1-SAMPLE-01's hello scenario (Debug+ASan vs + # Release, plus g++ vs clang++ on Linux) — is SKIPPED until that + # sample lands; M1-DET-04 activates it and records the result per + # ARCH-010. Like include-lint and api-manifest, the job runs on every + # PR and merge, independent of the ci:* label selector — it is a + # tooling check, not an additional P0 OS build (the PRD §14 cadence + # is unchanged). + name: Determinism check + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + - name: Build + run: cmake --build build -j + - name: Synthetic scenario self-check + run: ./build/bin/laige-detcheck --scenario=synthetic + - name: Real scenario (M1-SAMPLE-01) + # Skipped until M1-SAMPLE-01 lands the hello scenario binary; + # M1-DET-04 replaces this step with the two-configuration + # comparison, e.g.: + # ./build/bin/laige-detcheck --run-a=build-asan/bin/hello + # --run-b=build/bin/hello + run: | + if [ -x samples/hello/bin/hello ]; then + ./build/bin/laige-detcheck \ + --run-a=samples/hello/bin/hello \ + --run-b=samples/hello/bin/hello + else + echo "skipped: no real scenario yet (M1-SAMPLE-01)" + fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 142b93f..319e801 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -224,7 +224,8 @@ if(LAIGE_BUILD_TESTS) add_subdirectory(tests) # Dev tools that need the built engine library (gated with tests: a # library-only build does not need them). - add_subdirectory(tools/fuzz) - add_subdirectory(tools/bench) # M0-CORE-08: laige-bench - add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner + add_subdirectory(tools/fuzz) # M0-CORE-07: laige-fuzz + add_subdirectory(tools/bench) # M0-CORE-08: laige-bench + add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner + add_subdirectory(tools/detcheck) # M0-TOOL-02: laige-detcheck endif() diff --git a/README.md b/README.md index 8b08263..952c036 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The AI-relevant hardware the model runs on: | `deps/` | Vendored dependencies, tracked by `deps.lock` (PRD §11) — lands in M0-DEP-01 | | `third_party/` | Reserved placeholder for vendored code outside `deps.lock` | | `tests/` | Unit/integration tests, mirroring the `src/` module layout | -| `tools/` | Engine tools and CI scripts (fuzz runner, API manifest, lints) | +| `tools/` | Engine tools and CI scripts (fuzz runner, API manifest, determinism checker, lints) | | `samples/` | Reference game projects (flagship isometric ARPG, platformer, lockstep arena, MMO demo zone) | | `docs/` | Documentation; [decision index](docs/decisions/README.md) (full structure lands in M0-DOC-01) | diff --git a/docs/README.md b/docs/README.md index 2b7e6ee..5cd47c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,9 +9,8 @@ sections below mark what exists and what is still to land. - [Building Laige](getting-started/building.md) — the source of truth for the canonical build commands, build trees, options, compiler policy (NFR-8.10), sanitizer builds (NFR-8.2), and the current M0 status. - Tool commands are reserved there: `laige-fuzz`, `laige-bench`, - `laige-detcheck`, the `laige-api` manifest target, and the - include-graph lint. + Tool commands: `laige-fuzz`, `laige-bench`, `laige-detcheck`, the + `laige-api` manifest target, and the include-graph lint. ## API contracts (per public header) @@ -31,6 +30,9 @@ sections below mark what exists and what is still to land. schema (M0-CORE-08). - [PRNG](api/prng.md) — `laige::Prng`: the splitmix64/LCG64 hybrid, period, and determinism contract (M0-CORE-06). +- [Determinism checker](api/detcheck.md) — the `laige-detcheck` tool and + the scenario hash-line contract (` ` lines, two build + configurations) (M0-TOOL-02). ## Architecture decisions (ADRs) diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md new file mode 100644 index 0000000..73b5a82 --- /dev/null +++ b/docs/api/detcheck.md @@ -0,0 +1,143 @@ +# Determinism checker (`laige-detcheck`) and the scenario contract + +The M0 skeleton of the PRD FR-11.5 determinism checker (roadmap step +**M0-TOOL-02**; AGENTS ARCH-010, TEST-004). Implementation: +`tools/detcheck/laige-detcheck.cpp` (the normative scenario contract is +in its header comment, which this page mirrors); CTest suite: +`ctest -R detcheck` (`tests/detcheck`). Canonical command form: +[building.md](../getting-started/building.md). + +`laige-detcheck` runs a named scenario in **two build configurations** +(e.g. Debug+ASan vs Release, or two compiler builds) and asserts that the +per-tick state hashes are identical — bit-identity of the deterministic +state across configurations (NFR-8.3, FR-1.4). The engine state-hash API +arrives with **M1-DET-03** (`world.state_hash`); this tool therefore works +against the **hash-file output contract** defined below and compares two +such streams. + +## Scenario contract + +A scenario is a deterministic program that simulates a fixed number of +ticks from a fixed seed and prints exactly one line per simulated tick, +in order, to **stdout**: + +```text + +``` + +- **``** — non-negative decimal integer, no padding or leading + zeros. The first line is tick `0`; each later tick is exactly one + higher (no gaps, no duplicates). +- **``** — exactly **16 lowercase hex digits**, the canonical text + form of a 64-bit state hash (M1-DET-03's `world.state_hash`). The hash + *algorithm* is not part of the contract: `laige-detcheck` compares the + lines byte-for-byte, so only run-to-run identity matters. +- One line separator per line; a trailing newline on the final line is + optional, and an optional trailing `\r` is tolerated (Windows CRLF). +- **stderr** is ignored by the checker (it remains visible in the CI job + log). +- The scenario exits `0` on completion; any other exit code is a + scenario failure. + +The checker enforces the contract strictly (a malformed scenario is a +loud exit-2 error, never a silent mismatch — CORE-008) and bounds the +output: at most **65536** ticks and 64 bytes per line. + +## Command line + +```text +laige-detcheck --scenario= [--ticks=N] [--seed=HEX|DEC] +laige-detcheck --run-a= --run-b= + [-- scenario-args...] +``` + +**Mode 1 — `--scenario`** (M0 built-in scenarios, in-process): + +| Name | Meaning | +|---|---| +| `synthetic` | 32 `fpx16_16` bodies + seeded `laige::Prng` input, run twice in two identical configurations — the self-check | +| `synthetic-perturbed` | the same, but run-b adds 1 unit to body 3's x at tick 7 — the perturbation fixture that proves the failure path | + +`--ticks` (1..65536, default 256) and `--seed` (0xHEX or decimal, default +`0x1de7c0de`) apply to the built-in scenario only. + +**Mode 2 — `--run-a`/`--run-b`** (the real mode, activated by +**M1-DET-04**): two builds of the same scenario source (two build +configurations) are executed and their hash streams compared. Everything +after the `--` separator is passed to both scenario binaries, so +scenario arguments can never collide with tool flags. + +## Report and exit codes + +stdout (stable and machine-greppable — LOG-001): + +```text +detcheck scenario=synthetic result=OK ticks=256 + run-a: synthetic[seed=0x1de7c0de ticks=256 build=Debug] + run-b: synthetic[seed=0x1de7c0de ticks=256 build=Debug] +``` + +```text +detcheck scenario=synthetic-perturbed result=DIVERGED first_diff_tick=7 + run-a: 7 485959cdde7acb9c + run-b: 7 93a3363d5a1dffa9 +``` + +In mode 2 the first line is +`detcheck scenario= vs result=... ticks=` and +the `run-a`/`run-b` lines carry the full scenario paths. When one stream +ends early, the tick lines become stream-length notes +(`stream ends: ticks`) with `result=DIVERGED`. + +| Exit | Meaning | +|---|---| +| 0 | the two runs agree on every tick (deterministic) | +| 1 | divergence detected (a determinism failure — loud, CORE-008) | +| 2 | usage error, unknown scenario, a scenario run failed (non-zero exit, spawn failure), or a scenario violated the output contract (malformed line, tick gap, unbounded output) | + +## The built-in synthetic workload + +32 bodies of Q16.16 position/velocity (the default deterministic backend, +ADR 0002). Each tick: fixed-order integration (`x += vx`, `y += vy`), +wrap into a 64-unit box, then one seeded input event — the Prng picks the +body index and a nudge in [-4, 3] applied to x. Per-tick hash: FNV-1a 64 +(the house constants, same as the `math_fixed` known-answer test) over +(tick, seed, every body's four raw words), big-endian per word +(endianness-independent). + +**Determinism scope (ARCH-010):** pure unsigned-integer arithmetic +(`fpx16_16` ops + xorshift128+) — bit-exact across build, platform, ISA, +and compiler by the language standard; no float anywhere in the workload. +**Hash scope (M0):** tick counter + seed + body words. The Prng position +is a pure function of (seed, nudge history) in this workload; the +definitive scope — including PRNG state and the exact hash function — is +defined by M1-DET-03's `world.state_hash`, which replaces the ad-hoc FNV +computation of scenarios (the tool's line-by-line comparison is unchanged). + +## Performance and bounds + +A CI tool, not a hot path: one scenario run is O(ticks × 32); captured +streams are bounded (65536 lines × 64 bytes ≈ 1.5 MiB worst case per +run). Process execution is a plain pipe capture (fork/exec on POSIX, +`CreateProcessW` on Windows) — no shell, no temporary files, bounded +memory, and the scenario's stderr stays on the CI log. + +## Test suite + +`ctest -R detcheck` (`tests/detcheck`) — the tool tested with a synthetic +two-run scenario: the built-in self-check, the built-in perturbation +fixture, and the cross-binary mode against fixture scenario binaries +(one source, five compiled variants: clean / perturbed / bad output / +early exit / short stream). Each test is a generated `cmake -P` check +script asserting both the exit code and the required output fragments +(same pattern as `tests/api`). + +## CI status + +The `detcheck` CI job (`.github/workflows/ci.yml` and `ci-pull.yml`) runs +the built-in self-check on every PR and merge (like `include-lint` and +`api-manifest`, independent of the `ci:*` label selector — it is a +tooling check, not an additional P0 OS build). The real-scenario +comparison — two build configurations of M1-SAMPLE-01's `hello` — is +**skipped until that sample exists**; **M1-DET-04** activates it and +records the result per ARCH-010. diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index f5e5935..e97d853 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -41,12 +41,12 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) Notes: - `Debug` is the canonical `CMAKE_BUILD_TYPE`; `Release` is supported. -- The four rows above the lint row name tools that land in later M0 steps — - `laige-fuzz` (minimal form from M0-CORE-07: the `json_parse` target and - deterministic bounded runs; M0-TEST-01 extends it with CI lane semantics - and nightly long runs), `laige-bench` (M0-CORE-08), `laige-detcheck` - (M0-TOOL-02), target `laige-api` (M0-TOOL-01). Their command forms are - fixed here now so later steps cannot drift. +- The tool rows above the lint row are live targets now: `laige-fuzz` + (minimal form from M0-CORE-07: the `json_parse` target and deterministic + bounded runs; M0-TEST-01 extends it with CI lane semantics and nightly + long runs), `laige-bench` (M0-CORE-08), `laige-detcheck` (M0-TOOL-02), + and target `laige-api` (M0-TOOL-01). Their command forms were fixed here + when they were reserved, so no step can drift them. - Include-graph lint (M0-CI-03): platform-independent (Python 3 stdlib only, no setup). It parses the `#include` edges of `src/**` and enforces the PRD §10.1 rules (laige-core includes nothing internal; arrows only @@ -158,7 +158,8 @@ pinned set and the NaN/Inf policy): and the fpx16_16 rounding/saturation policy in [docs/api/sim_math.md](../api/sim_math.md), pinned flags via `laige_apply_simmath_policy()`), and the memory pools from M0-CORE-05 - (`include/laige/pools.h`: `laige::ArenaPool` and `laige::Pool` with `laige::PoolStats` accounting; API contract in [docs/api/pools.md](../api/pools.md)), and the bounded JSON parser + serializer from M0-CORE-07 (`include/laige/json.h`, `json.cpp`: `laige::JsonValue`, `parseJson`, `serializeJson`, `JsonOptions`; API contract in [docs/api/json.md](../api/json.md)).- `tests/laige-core/laige-core_tests` is a CTest link smoke test (a + (`include/laige/pools.h`: `laige::ArenaPool` and `laige::Pool` with `laige::PoolStats` accounting; API contract in [docs/api/pools.md](../api/pools.md)), and the bounded JSON parser + serializer from M0-CORE-07 (`include/laige/json.h`, `json.cpp`: `laige::JsonValue`, `parseJson`, `serializeJson`, `JsonOptions`; API contract in [docs/api/json.md](../api/json.md)). +- `tests/laige-core/laige-core_tests` is a CTest link smoke test (a GoogleTest suite since M0-DEP-01) that runs in every build tree above: it verifies the static/shared link and checks the NFR-8.10 policy flags with `static_assert` (a policy violation fails the build). @@ -208,6 +209,23 @@ pinned set and the NaN/Inf policy): manifest and fails on any drift (PRD §9.4, NFR-13.1). The manifest contract (symbol kinds, doc association, exit codes, unsupported constructs) is the header comment of `tools/api/laige-api.cpp`. +- `detcheck-synthetic`, `detcheck-synthetic-perturbed`, + `detcheck-bin-identical`, `detcheck-bin-identical-args`, + `detcheck-bin-diverged`, `detcheck-bin-malformed`, + `detcheck-scenario-failure`, `detcheck-stream-mismatch`, and + `detcheck-unknown-scenario` are the M0-TOOL-02 CTest entries + (`tests/detcheck`): the determinism checker + (`tools/detcheck/laige-detcheck`) runs the synthetic two-run scenario — + the built-in `synthetic` self-check, the built-in perturbation fixture, + and the cross-binary mode against fixture scenario binaries (one source, + five compiled variants) — asserting both the exit code and the required + output fragments per test (`ctest -R detcheck`). The scenario contract + (` ` lines, 16 lowercase hex hash digits) and the tool's + report/exit-code grammar are in + [docs/api/detcheck.md](../api/detcheck.md); the CI `detcheck` job runs + the self-check on every PR and merge, with the real-scenario comparison + (two build configurations) skipped until M1-SAMPLE-01 (M1-DET-04 + activates it). - Every configure verifies the vendored dependency lock (`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or unlisted file under `deps/` fails the configure loudly. GoogleTest is the diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index 7f30970..970c955 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -796,7 +796,7 @@ No rendering, no physics, no networking yet — `laige-core` only. manifest: 376 symbols from 10 headers (all in `laige-core`; the other M0 modules have no `include/` directories yet). -- [ ] **M0-TOOL-02 · Determinism checker skeleton** +- [x] **M0-TOOL-02 · Determinism checker skeleton** - **Refs:** FR-11.5; AGENTS ARCH-010, TEST-004 - **Depends:** M0-CORE-06, M0-CORE-08 - **Scope:** @@ -804,7 +804,46 @@ No rendering, no physics, no networking yet — `laige-core` only. - Scenario contract documented: scenario binary prints ` ` lines. - Wire into CI as a job that is skipped until a real scenario exists (M1-SAMPLE-01), but the tool itself is tested with a synthetic two-run scenario. - **Verify:** `laige-detcheck --scenario=synthetic` passes on identical builds and fails when the synthetic scenario is perturbed (test fixture). - - **Size:** ~200 lines + test + - **Decision (2026-09-12):** `laige-detcheck` in `tools/detcheck` + (single C++20 file, over the ~200-line estimate: the normative + scenario contract is the file's header comment — same pattern as + M0-TOOL-01 — and portable scenario execution needs both fork/exec + + pipe capture (POSIX) and CreateProcessW + PeekNamedPipe (Windows) + so the tool builds on every P0 platform). Two modes: + `--scenario=synthetic|synthetic-perturbed` (built-in 32-body + fpx16_16 + Prng workload, two in-process runs — pure integer + arithmetic, bit-exact per ADR 0002) and the real M1-DET-04 mode + `--run-a= --run-b= [-- scenario-args...]` (two builds of + one scenario compared). Scenario contract (strict, enforced, + bounded): one stdout line per tick ` ` — tick starts at + 0, step 1, no padding; hash = 16 lowercase hex digits (the 64-bit + state hash's canonical text form; the algorithm is NOT part of the + contract — lines compare byte-for-byte); trailing newline optional, + trailing `\r` tolerated; stderr ignored; exit 0 on completion. + Bounds: ≤ 65536 ticks, ≤ 64 bytes/line. Report: stable + `detcheck scenario= result=OK|DIVERGED [first_diff_tick=]` + + run-a/run-b lines (the diverging tick pair, or stream-length notes + when one run ends early). Exit 0 = match · 1 = divergence (loud, + CORE-008) · 2 = usage/unknown scenario/scenario failure/contract + violation. Contract doc: `docs/api/detcheck.md` (normative text in + the tool header). Tests: 9 CTest entries in `tests/detcheck` + (synthetic self-check; perturbation fixture — built-in and as a + fixture binary; identical/different cross-binary pairs; malformed + output; scenario exit failure; stream-length mismatch; unknown + scenario), each a generated `cmake -P` check script asserting exit + code + output fragments (tests/api pattern; WILL_FAIL inversion and + crash-vs-failure reasons as documented there). Fixture: one source, + five compiled variants (clean/perturbed/bad/fail/short) — the + synthetic two-run scenario. CI: `detcheck` job in ci-pull.yml/ci.yml + (every PR and merge, no ci:* condition — tooling check, not a P0 OS + build) runs the synthetic self-check and SKIPS the real-scenario + step until M1-SAMPLE-01 exists (M1-DET-04 activates the + two-configuration comparison). M1-DET-03's world.state_hash + replaces the scenarios' ad-hoc FNV computation; the tool's + line-comparison is unchanged. + - **Size:** ~1,400 lines (over estimate: contract header + dual- + platform process execution + 9-test suite + fixture variants + + docs/api/detcheck.md; see Decision) ## Test infrastructure & docs diff --git a/roadmap/README.md b/roadmap/README.md index dae3fea..dc4c308 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -155,7 +155,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| -| M0 | 22 | 17 | ▶ in progress | +| M0 | 22 | 19 | ▶ in progress | | M1 | 25 | 0 | ⬜ not started | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | @@ -190,6 +190,8 @@ One line per completed (or split/renumbered) step. | 2026-09-11 | M0-CORE-06 | `a82de8e` | `laige::Prng`: xorshift128+ transcribed from and verified against the reference (all-zero state excluded), splitmix64 seeding (bijection), per-substream derivation (documented composition rule), Lemire unbiased `next_range`, `next_float01` = k·2^-24 exact; full period 2^128 − 1 for every nonzero state proven (characteristic polynomial over GF(2) via Berlekamp–Massey + irreducibility/primitivity checks; portable 128-bit arithmetic — no `__int128`, MSVC-safe); `prng` CTest entry (18 cases incl. the committed period proof and algorithm-sensitive golden KAT); API contract in `docs/api/prng.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-07 | `41938b0` | Bounded JSON in laige-core (ADR 0003, no new dependency): `laige::JsonValue` (deep copy, O(1) move, deep equality) + `parseJson` (1 MiB / depth-32 bounds, strict UTF-8, duplicate keys rejected, ±inf for overflow tokens — documented) + `serializeJson` (canonical ASCII; shortest-round-trip numbers; `std::to_chars` avoided for AppleClang 15 compatibility); all failures → `MalformedInput` (3); `laige-fuzz` minimal deterministic runner (Prng-seeded, `--runs`/`--seed`, 19-document corpus) + `json_parse` fuzz target; `config_json` CTest entry (25 cases) + `fuzz_json_parse` instrumented entry (ASan tree); API contract in `docs/api/json.md` (board/changelog row reconstructed 2026-09-11 from the step record) | | 2026-09-11 | M0-CORE-08 | `810c251` | Budget harness: `laige::Histogram` (fixed-capacity rolling window; O(1) allocation-free `record()`; exact min/mean/p50/p95/p99/max over the stored window via nearest-rank percentiles; allocation-free O(n log n) `stats()`) + `laige::TimeIt` (`steady_clock` ms scope timer) + `loadBudgets`/`budgetCheck` (strict `budgets.json` schema v1 via the bounded JSON parser — ARCH-007; loud `NO_SAMPLES` failure on empty histograms; `target == 0` = hard-zero budget, not "not set"; AGENTS §12 report: stable 4-line text, before/after pair, caller context; `formatStatsLine` the single source of the stats text) + repo-root `budgets.json` (all 15 PRD §8.1 entries; `measured: 0` = not yet measured) + `laige-bench` tool (canonical command per `building.md`: `--suite=synthetic` deterministic 4096-step LCG+double stand-in workload, `--runs`/`--warmup`, `--budget=` check, exit code 2 on budget failure, `--report` append); `budget_harness` CTest entry (22 cases) + `laige_bench_smoke` CTest entry; **bug fix (M0-CORE-07)** found by this step's Verify run: `json.cpp` `parseObjectMembers` missing `skipWhitespace` before the member key — object documents with `", "` between members (the hand-formatted `budgets.json`) were rejected; regression test `ConfigJsonValid.ObjectMemberWhitespace` (fails pre-fix); API contract in `docs/api/budget_harness.md`; local Verify: `ctest` 16/16, zero warnings on GCC static/shared/ASan/TSan + Clang trees; MSVC/AppleClang compile proof lands in CI | +| 2026-09-12 | M0-TOOL-01 | `937ac7c` | API manifest (NFR-13.1, PRD §9.4): `laige-api` target + `tools/api/laige-api-scanner` (line-oriented state machine — no regex pass; loud failure on every unsupported construct; doc association from consecutive `//` blocks with `@budget`/`@experimental` tags; `--check FILE` byte compare + symbol-level diff via `laige::parseJson`; exit 0 OK / 1 stale / 2 error) + checked-in `laige-api.json` (version 1, deterministic, no timestamps — byte-identical regeneration is the drift check) + `tests/api` CTest entries (fixture tree with exact manifest bytes, fresh/stale, unsupported-construct failure, real-tree `--check`) + the `api-manifest` CI job (every PR and merge, fails on drift); (board/changelog row retroactively added 2026-09-12 — the step merged as `937ac7c`/PR #10 without updating this board or log) | +| 2026-09-12 | M0-TOOL-02 | `fe4460c` | Determinism checker skeleton (FR-11.5, AGENTS ARCH-010, TEST-004): `laige-detcheck` (`tools/detcheck`) runs a named scenario in two build configurations and compares per-tick hash streams; scenario contract (normative in the tool header, mirrored in `docs/api/detcheck.md`): one stdout line per tick ` ` — 16 lowercase hex hash digits (algorithm NOT part of the contract — lines compare byte-for-byte), tick starts at 0 step 1 no padding, trailing newline optional / trailing `\r` tolerated, stderr ignored, exit 0 — enforced strictly and bounded (65536 ticks, 64 bytes/line; a violation is a loud exit 2, CORE-008); modes: `--scenario=synthetic\|synthetic-perturbed` (built-in 32-body `fpx16_16`+Prng workload, two in-process runs — pure integer arithmetic, bit-exact per ADR 0002; perturbation fixture: +1 unit to body 3's x at tick 7) and `--run-a= --run-b= [-- scenario-args...]` (the M1-DET-04 mode; `--` separator keeps scenario args unambiguous); stable report `detcheck scenario= result=OK\|DIVERGED [first_diff_tick=]` + run-a/run-b lines (LOG-001); exit 0 match / 1 divergence / 2 error; POSIX fork/exec + pipe capture, Windows CreateProcessW + PeekNamedPipe (no shell, bounded memory, scenario stderr stays on the CI log); 9 CTest entries (`tests/detcheck`, `ctest -R detcheck`): self-check, built-in perturbation, identical/diverged/malformed/failure/short-stream cross-binary pairs — one fixture source, five compiled variants — each a generated `cmake -P` script asserting exit code + output fragments (tests/api pattern); CI `detcheck` job in ci-pull.yml/ci.yml (every PR and merge, no ci:* condition — tooling check, not a P0 OS build): runs the synthetic self-check and SKIPS the real-scenario step (two build configurations of M1-SAMPLE-01's hello) until it lands — M1-DET-04 activates it; local Verify: ctest 31/31, zero warnings on g++ static/shared, clang++, ASan+UBSan, TSan trees; Windows path compile-verified by the CI MSVC job; CI (observed 2026-09-12 via the GitHub API): `ci-pull.yml` run 34697638239 on `82c548d` green - the default-Linux lane's four P0/sanitizer jobs (linux-gcc, linux-clang, linux-asan+UBSan, linux-tsan) plus all three tooling jobs (include-lint, api-manifest, detcheck) passed; Windows/macOS jobs label-skipped as expected; the new `detcheck` job's real-scenario step correctly reported `skipped: no real scenario yet (M1-SAMPLE-01)` | --- diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2556209..47cb0e0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -71,3 +71,8 @@ add_subdirectory(tools) # Public API manifest checks (M0-TOOL-01): the scanner runs against fixture # trees and the real repository tree (tests/api). add_subdirectory(api) + +# Determinism checker checks (M0-TOOL-02): the tool runs the synthetic +# two-run scenario (built-in + fixture scenario binaries) in +# tests/detcheck. +add_subdirectory(detcheck) diff --git a/tests/detcheck/CMakeLists.txt b/tests/detcheck/CMakeLists.txt new file mode 100644 index 0000000..23129e7 --- /dev/null +++ b/tests/detcheck/CMakeLists.txt @@ -0,0 +1,165 @@ +# laige-detcheck CTest suite (M0-TOOL-02) +# +# The tool is tested with a synthetic two-run scenario (the step's +# Verify clause): the built-in `synthetic` scenario self-check, plus the +# real cross-binary mode against fixture scenario binaries — one source +# (detcheck-fixture-scenario.cpp) compiled in five variants, the clean +# copy being configuration A and the perturbed copy configuration B: +# +# detcheck-synthetic built-in --scenario=synthetic exit 0 +# detcheck-synthetic-perturbed built-in, perturbed run-b exit 1 +# detcheck-bin-identical fixture A vs fixture A exit 0 +# detcheck-bin-identical-args both, with pass-through --ticks=16 exit 0 +# detcheck-bin-diverged fixture A vs perturbed copy exit 1 +# detcheck-bin-malformed fixture A vs bad-output copy exit 2 +# detcheck-scenario-failure fixture that exits 3 at tick 5 exit 2 +# detcheck-stream-mismatch 16-tick fixture vs 32-tick fixture exit 1 +# detcheck-unknown-scenario --scenario=bogus exit 2 +# +# Each test is a generated `cmake -P` check script +# (expect-detcheck-result.cmake.in) that asserts the exit code AND the +# required output fragments — the same pattern as tests/api (CTest +# inverts PASS_REGULAR_EXPRESSION when WILL_FAIL is set, and a crash +# exits non-zero like a correct failure, so content must be checked). +# The real-tree scenario comparison (M1-DET-04) is not exercised here: +# it is wired in the CI `detcheck` job and activates with M1-SAMPLE-01. + +# --- The fixture scenario (the synthetic two-run scenario) ----------------- +# One source, five compiled variants (one flag each) — see the source +# header for what each variant does. No engine link: the fixture tests +# the contract plumbing, not the engine math. +add_executable(detcheck-fixture-scenario detcheck-fixture-scenario.cpp) +laige_apply_engine_policy(detcheck-fixture-scenario) + +add_executable(detcheck-fixture-scenario-perturbed detcheck-fixture-scenario.cpp) +laige_apply_engine_policy(detcheck-fixture-scenario-perturbed) +target_compile_definitions(detcheck-fixture-scenario-perturbed + PRIVATE DETCHK_FIXTURE_PERTURB_TICK=7) + +add_executable(detcheck-fixture-scenario-bad detcheck-fixture-scenario.cpp) +laige_apply_engine_policy(detcheck-fixture-scenario-bad) +target_compile_definitions(detcheck-fixture-scenario-bad + PRIVATE DETCHK_FIXTURE_BAD_OUTPUT=1) + +add_executable(detcheck-fixture-scenario-fail detcheck-fixture-scenario.cpp) +laige_apply_engine_policy(detcheck-fixture-scenario-fail) +target_compile_definitions(detcheck-fixture-scenario-fail + PRIVATE DETCHK_FIXTURE_FAIL_TICK=5 DETCHK_FIXTURE_FAIL_EXIT=3) + +add_executable(detcheck-fixture-scenario-short detcheck-fixture-scenario.cpp) +laige_apply_engine_policy(detcheck-fixture-scenario-short) +target_compile_definitions(detcheck-fixture-scenario-short + PRIVATE DETCHK_FIXTURE_TICKS=16) + +# The test environment shared by every entry: the tool and all five +# fixture paths (the template reads them via $ENV{}; $ +# resolves per configuration under multi-config generators). Built with +# string(APPEND) so the result is ONE string: a multi-argument set() +# would double the ';' separators (verified on CMake 4.4.3). +set(LAIGE_DETCHECK_TEST_ENV + "LAIGE_DETCHECK=$;") +string(APPEND LAIGE_DETCHECK_TEST_ENV + "LAIGE_DETCHECK_FIX_A=$;") +string(APPEND LAIGE_DETCHECK_TEST_ENV + "LAIGE_DETCHECK_FIX_PERTURBED=$;") +string(APPEND LAIGE_DETCHECK_TEST_ENV + "LAIGE_DETCHECK_FIX_BAD=$;") +string(APPEND LAIGE_DETCHECK_TEST_ENV + "LAIGE_DETCHECK_FIX_FAIL=$;") +string(APPEND LAIGE_DETCHECK_TEST_ENV + "LAIGE_DETCHECK_FIX_SHORT=$") + +# --- The tests --------------------------------------------------------------- +# Same shape as the tests/api helper: a generated cmake -P check script +# asserts the exit code and the required output fragments. +function(laige_add_detcheck_test name expect_exit cmd) + set(_checks "") + foreach(_needle IN LISTS ARGN) + # Escape ECMAScript metacharacters for the generated MATCHES check. + string(REPLACE "\\" "\\\\" _esc "${_needle}") + string(REPLACE "\"" "\\\"" _esc "${_esc}") + string(REPLACE "(" "\\(" _esc "${_esc}") + string(REPLACE ")" "\\)" _esc "${_esc}") + string(REPLACE "+" "\\+" _esc "${_esc}") + string(REPLACE "." "\\." _esc "${_esc}") + string(REPLACE "*" "\\*" _esc "${_esc}") + string(REPLACE "?" "\\?" _esc "${_esc}") + string(REPLACE "|" "\\|" _esc "${_esc}") + string(REPLACE "^" "\\^" _esc "${_esc}") + string(REPLACE "$" "\\$" _esc "${_esc}") + string(REPLACE "[" "\\[" _esc "${_esc}") + string(REPLACE "]" "\\]" _esc "${_esc}") + string(REPLACE "{" "\\{" _esc "${_esc}") + string(REPLACE "}" "\\}" _esc "${_esc}") + string(APPEND _checks + "if(NOT _text MATCHES \"${_esc}\")\n" + " string(APPEND _problems \"output missing '<${_esc}'>; \")\n" + "endif()\n") + endforeach() + set(CMD "${cmd}") + set(EXPECT_EXIT "${expect_exit}") + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-detcheck-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") + # A bounded timeout: a hung scenario capture would otherwise hang ctest. + set_tests_properties(${name} PROPERTIES TIMEOUT 120) + # The executable paths are carried in the test environment, not in the + # command or baked into the script: $ resolves per + # configuration under multi-config generators (see the template header). + # The quoted expansion keeps the ';'-separated pairs in ONE argument. + set_tests_properties(${name} PROPERTIES + ENVIRONMENT "${LAIGE_DETCHECK_TEST_ENV}") +endfunction() + +# (name, expected exit, quoted command list, required output fragments...) +laige_add_detcheck_test(detcheck-synthetic 0 + "--scenario=synthetic" + "detcheck scenario=synthetic result=OK ticks=256" + "run-a: synthetic\[seed=") + +laige_add_detcheck_test(detcheck-synthetic-perturbed 1 + "--scenario=synthetic-perturbed" + "detcheck scenario=synthetic-perturbed result=DIVERGED first_diff_tick=7") + +laige_add_detcheck_test(detcheck-bin-identical 0 + "--run-a=$FIX_A$ --run-b=$FIX_A$" + "result=OK ticks=32") + +laige_add_detcheck_test(detcheck-bin-identical-args 0 + "--run-a=$FIX_A$ --run-b=$FIX_A$ -- --ticks=16" + "result=OK ticks=16") + +laige_add_detcheck_test(detcheck-bin-diverged 1 + "--run-a=$FIX_A$ --run-b=$FIX_PERTURBED$" + "result=DIVERGED first_diff_tick=7") + +laige_add_detcheck_test(detcheck-bin-malformed 2 + "--run-a=$FIX_A$ --run-b=$FIX_BAD$" + "malformed line 4") + +laige_add_detcheck_test(detcheck-scenario-failure 2 + "--run-a=$FIX_FAIL$ --run-b=$FIX_A$" + "scenario run-a: scenario process exited with code 3") + +laige_add_detcheck_test(detcheck-stream-mismatch 1 + "--run-a=$FIX_SHORT$ --run-b=$FIX_A$" + "result=DIVERGED first_diff_tick=16" + "stream ends: 16 ticks") + +laige_add_detcheck_test(detcheck-unknown-scenario 2 + "--scenario=bogus" + "unknown scenario 'bogus'") + +if(LAIGE_TSAN) + # Same first-report-fatal policy as the other tool entries (NFR-8.2). + # NB: CTest's ENVIRONMENT property is replaced (not appended) by a + # later set_tests_properties call, so the fixture paths are restated. + set_tests_properties( + detcheck-synthetic detcheck-synthetic-perturbed detcheck-bin-identical + detcheck-bin-identical-args detcheck-bin-diverged detcheck-bin-malformed + detcheck-scenario-failure detcheck-stream-mismatch + detcheck-unknown-scenario + PROPERTIES + ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;${LAIGE_DETCHECK_TEST_ENV}") +endif() diff --git a/tests/detcheck/detcheck-fixture-scenario.cpp b/tests/detcheck/detcheck-fixture-scenario.cpp new file mode 100644 index 0000000..42d9e8e --- /dev/null +++ b/tests/detcheck/detcheck-fixture-scenario.cpp @@ -0,0 +1,116 @@ +// detcheck-fixture-scenario — the fixture scenario for the laige-detcheck +// CTest tests (M0-TOOL-02). +// +// A self-contained deterministic scenario that implements the M0 +// hash-line contract (the normative text is in the header of +// tools/detcheck/laige-detcheck.cpp and in docs/api/detcheck.md): one +// ` ` line per tick on stdout (16 lowercase hex hash +// digits), starting at tick 0, exit 0. +// +// It is deliberately NOT linked against laige-core: the fixture tests +// the contract plumbing (capture, strict parsing, comparison, reports), +// not the engine math — the built-in `synthetic` scenario (fpx16_16 + +// Prng) and the math_fixed known-answer test cover that. +// +// One source, five CMake-built variants (one flag each): +// +// (no flag) clean: 32 ticks +// DETCHK_FIXTURE_PERTURB_TICK=7 run-b stand-in: adds 1 to state[3] +// at tick 7 (diverges from the clean +// run exactly at tick 7) +// DETCHK_FIXTURE_BAD_OUTPUT=1 prints a malformed line at tick 3 +// (detcheck must exit 2) +// DETCHK_FIXTURE_FAIL_TICK=5 exits with DETCHK_FIXTURE_FAIL_EXIT +// DETCHK_FIXTURE_FAIL_EXIT=3 at tick 5 (scenario failure; +// detcheck must exit 2) +// DETCHK_FIXTURE_TICKS=16 only 16 ticks (stream-length +// mismatch vs the 32-tick clean run; +// detcheck must report DIVERGED) +// +// State: 16 u32 words advanced by one named 64-bit LCG per word (the +// Marsaglia 64-bit LCG constants, the same house choice as +// laige-bench's synthetic workload — CPP-014); the per-tick hash is +// FNV-1a 64 over (tick, the state words), big-endian per word — the +// same FNV constants as laige-detcheck. Pure unsigned-integer +// arithmetic: bit-exact on every P0 platform (ARCH-010). + +#include +#include +#include + +namespace { + +constexpr std::uint64_t kLcgMultiplier = 6364136223846793005ull; +constexpr std::uint64_t kLcgIncrement = 1442695040888963407ull; +constexpr std::uint64_t kSeed = 0x1234567890ABCDEFull; +constexpr int kStateWords = 16; +constexpr std::uint64_t kFnvOffsetBasis = 0xcbf29ce484222325ull; +constexpr std::uint64_t kFnvPrime = 0x100000001b3ull; + +#ifndef DETCHK_FIXTURE_TICKS +#define DETCHK_FIXTURE_TICKS 32 +#endif + +// Strict decimal parse bounded to 1..4096 (fixture runs are tiny; the +// bound keeps the argument surface minimal). +int parseTicks(std::string_view value) { + if (value.empty()) return -1; + std::uint64_t v = 0; + for (const char c : value) { + if (c < '0' || c > '9') return -1; + if (v > (4096 - static_cast(c - '0')) / 10) return -1; + v = v * 10 + static_cast(c - '0'); + } + return static_cast(v); +} + +} // namespace + +int main(int argc, char** argv) { + int ticks = DETCHK_FIXTURE_TICKS; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg.rfind("--ticks=", 0) == 0) { + ticks = parseTicks(arg.substr(8)); + if (ticks < 1) return 2; + } else { + return 2; // unknown argument: a scenario failure, not a crash + } + } + + std::uint64_t lcg = kSeed; + std::uint32_t state[kStateWords] = {0}; + for (int t = 0; t < ticks; ++t) { + // Fixed-order advance: one LCG step mixed into each state word. + for (int i = 0; i < kStateWords; ++i) { + lcg = lcg * kLcgMultiplier + kLcgIncrement; + state[i] = static_cast(lcg) ^ + (static_cast(lcg >> 32) + state[i]); + } +#if defined(DETCHK_FIXTURE_PERTURB_TICK) + if (t == DETCHK_FIXTURE_PERTURB_TICK) state[3] += 1; +#endif +#if defined(DETCHK_FIXTURE_BAD_OUTPUT) + if (t == 3) { + std::printf("3 not-a-hash\n"); // contract violation + continue; + } +#endif +#if defined(DETCHK_FIXTURE_FAIL_TICK) + if (t == DETCHK_FIXTURE_FAIL_TICK) return DETCHK_FIXTURE_FAIL_EXIT; +#endif + std::uint64_t h = kFnvOffsetBasis; + auto feed = [&h](std::uint64_t v) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (v >> shift) & 0xFFull; + h *= kFnvPrime; + } + }; + feed(static_cast(static_cast(t))); + for (int i = 0; i < kStateWords; ++i) { + feed(state[i]); + } + std::printf("%d %016llx\n", t, static_cast(h)); + } + return 0; +} diff --git a/tests/detcheck/expect-detcheck-result.cmake.in b/tests/detcheck/expect-detcheck-result.cmake.in new file mode 100644 index 0000000..16e1fc9 --- /dev/null +++ b/tests/detcheck/expect-detcheck-result.cmake.in @@ -0,0 +1,104 @@ +# Generated by tests/detcheck/CMakeLists.txt for one laige-detcheck CTest +# test (M0-TOOL-02) — do not edit. +# +# Runs laige-detcheck with the argument list @CMD@ and asserts BOTH the +# exit code (@EXPECT_EXIT@) and the required output fragments (the +# needle checks below are generated per test). The assertions live here, +# in a CMake script, instead of in CTest properties: CTest inverts +# PASS_REGULAR_EXPRESSION when WILL_FAIL is set (verified on CMake 4.4.3 +# — a matching regex then makes the test fail), and a crash exits +# non-zero just like a correct failure (CORE-008), so the content must +# be checked, not just the code. +# +# The executable paths arrive through the test's ENVIRONMENT property +# (same pattern as tests/api/expect-api-result.cmake.in: $ +# resolves per configuration under multi-config generators, so a path +# baked in at configure time only matches the single-config layout). +# The $FIX_*$ markers in @CMD@ name those environment variables +# (dollar form, so configure_file's @ONLY substitution leaves them +# untouched). +# +# The script exits non-zero (failing the CTest test) on any mismatch and +# prints the full tool output for diagnosis. + +set(_detcheck "$ENV{LAIGE_DETCHECK}") +if(_detcheck STREQUAL "") + message(FATAL_ERROR + "LAIGE_DETCHECK is not set: the test's ENVIRONMENT property must " + "carry the laige-detcheck path (tests/detcheck/CMakeLists.txt)") +endif() +set(_fix_a "$ENV{LAIGE_DETCHECK_FIX_A}") +set(_fix_perturbed "$ENV{LAIGE_DETCHECK_FIX_PERTURBED}") +set(_fix_bad "$ENV{LAIGE_DETCHECK_FIX_BAD}") +set(_fix_fail "$ENV{LAIGE_DETCHECK_FIX_FAIL}") +set(_fix_short "$ENV{LAIGE_DETCHECK_FIX_SHORT}") + +# @CMD@ is a quoted list of arguments in the tool's canonical `--opt=value` +# form. A $FIX_*$ marker anywhere inside an argument names the +# environment variable that carries that fixture's path (a fixture the +# test does not use simply never appears). +set(_cmd @CMD@) +set(_args "") +foreach(_tok IN LISTS _cmd) + # Literal containment checks (string(FIND), not MATCHES): $ is a regex + # metacharacter, so the marker would need escaping there. + string(FIND _tok "$FIX_A$" _pos) + if(_pos GREATER_EQUAL 0 AND _fix_a STREQUAL "") + message(FATAL_ERROR + "LAIGE_DETCHECK_FIX_A is not set but the test's command " + "references $FIX_A$") + endif() + string(FIND _tok "$FIX_PERTURBED$" _pos) + if(_pos GREATER_EQUAL 0 AND _fix_perturbed STREQUAL "") + message(FATAL_ERROR + "LAIGE_DETCHECK_FIX_PERTURBED is not set but the test's command " + "references $FIX_PERTURBED$") + endif() + string(FIND _tok "$FIX_BAD$" _pos) + if(_pos GREATER_EQUAL 0 AND _fix_bad STREQUAL "") + message(FATAL_ERROR + "LAIGE_DETCHECK_FIX_BAD is not set but the test's command " + "references $FIX_BAD$") + endif() + string(FIND _tok "$FIX_FAIL$" _pos) + if(_pos GREATER_EQUAL 0 AND _fix_fail STREQUAL "") + message(FATAL_ERROR + "LAIGE_DETCHECK_FIX_FAIL is not set but the test's command " + "references $FIX_FAIL$") + endif() + string(FIND _tok "$FIX_SHORT$" _pos) + if(_pos GREATER_EQUAL 0 AND _fix_short STREQUAL "") + message(FATAL_ERROR + "LAIGE_DETCHECK_FIX_SHORT is not set but the test's command " + "references $FIX_SHORT$") + endif() + string(REPLACE "$FIX_A$" "${_fix_a}" _tok "${_tok}") + string(REPLACE "$FIX_PERTURBED$" "${_fix_perturbed}" _tok "${_tok}") + string(REPLACE "$FIX_BAD$" "${_fix_bad}" _tok "${_tok}") + string(REPLACE "$FIX_FAIL$" "${_fix_fail}" _tok "${_tok}") + string(REPLACE "$FIX_SHORT$" "${_fix_short}" _tok "${_tok}") + list(APPEND _args "${_tok}") +endforeach() + +execute_process( + COMMAND "${_detcheck}" ${_args} + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err +) + +set(_text "${_out} +${_err}") + +set(_problems "") +if(NOT _rc EQUAL @EXPECT_EXIT@) + set(_problems "exit code ${_rc} (expected @EXPECT_EXIT@)") +endif() +@CHECKS@ +if(NOT _problems STREQUAL "") + message(FATAL_ERROR + "laige-detcheck check failed [${_problems}]\n" + "--- laige-detcheck output ---\n${_text}\n" + "--- end of laige-detcheck output ---") +endif() +message("laige-detcheck check OK: exit @EXPECT_EXIT@, required output present") diff --git a/tools/README.md b/tools/README.md index f98b14a..1d6b4a3 100644 --- a/tools/README.md +++ b/tools/README.md @@ -15,5 +15,12 @@ Engine tools and CI scripts, each landing with its roadmap step: §11 dependency budget of 10. Runs in CI on every PR and merge (job `include-lint`), and as CTest checks in `tests/tools`. - `laige-api` — public API manifest generator (M0-TOOL-01) -- `laige-detcheck` — determinism checker (M0-TOOL-02) +- `laige-detcheck` — determinism checker skeleton (M0-TOOL-02, in + `tools/detcheck`): runs a named scenario in two build configurations + and compares the per-tick state-hash streams (scenario contract: + ` ` lines — 16 lowercase hex hash digits; full contract in + [docs/api/detcheck.md](../docs/api/detcheck.md)). The built-in + `synthetic` scenario is the M0 self-check, tested in `tests/detcheck`; + the CI `detcheck` job runs it on every PR and merge and skips the + real-scenario comparison (M1-SAMPLE-01) until M1-DET-04 activates it. - `laige-bench` — budget/benchmark harness (M0-CORE-08) diff --git a/tools/detcheck/CMakeLists.txt b/tools/detcheck/CMakeLists.txt new file mode 100644 index 0000000..df82e13 --- /dev/null +++ b/tools/detcheck/CMakeLists.txt @@ -0,0 +1,30 @@ +# laige-detcheck (M0-TOOL-02): the determinism checker skeleton +# (FR-11.5; AGENTS ARCH-010, TEST-004). +# +# Canonical command (docs/getting-started/building.md): +# +# ./build/bin/laige-detcheck --scenario= +# +# Runs a named scenario in two build configurations and compares the +# 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. +# +# Gated with the test suite like tools/fuzz, tools/bench, and tools/api: +# a library-only build does not need it. +# +# The built-in synthetic workload uses only fpx16_16 (pure integer +# arithmetic) and laige::Prng — no IEEE float — so it does not need +# laige_apply_simmath_policy (that policy pins fp32_pinned IEEE semantics; +# integer arithmetic is bit-exact by the language standard, ADR 0002). + +add_executable(laige-detcheck laige-detcheck.cpp) +laige_apply_engine_policy(laige-detcheck) +target_link_libraries(laige-detcheck PRIVATE laige-core) + +# The report's run labels record the build type (AGENTS 12 context); +# CMake stamps it so the binary does not have to guess. +target_compile_definitions(laige-detcheck PRIVATE + LAIGE_DETCHECK_BUILD_TYPE="${CMAKE_BUILD_TYPE}") diff --git a/tools/detcheck/laige-detcheck.cpp b/tools/detcheck/laige-detcheck.cpp new file mode 100644 index 0000000..442497c --- /dev/null +++ b/tools/detcheck/laige-detcheck.cpp @@ -0,0 +1,809 @@ +// laige-detcheck (M0-TOOL-02) — determinism checker skeleton (FR-11.5). +// +// Canonical command (docs/getting-started/building.md is the source of +// truth): +// +// ./build/bin/laige-detcheck --scenario= +// +// The step's scope: run a named scenario in two build configurations +// (e.g. Debug+ASan vs Release, or two compiler builds) and compare +// per-tick state hashes. The engine state-hash API arrives with +// M1-DET-03; this skeleton accepts the hash-file output contract — a +// stream of ` ` lines — and compares two such streams. +// +// ============================================================================ +// SCENARIO CONTRACT (documented here; also in docs/api/detcheck.md) +// ============================================================================ +// A scenario is a deterministic program (the M1 form is M1-SAMPLE-01's +// hello.laige, run headless with a fixed seed). It MUST: +// +// stdout: exactly one line per simulated tick, in order: +// +// +// +// - : non-negative decimal integer, no padding or leading +// zeros; the first line is tick 0 and each later tick is exactly +// one higher (no gaps, no duplicates). +// - : exactly 16 lowercase hex digits — the canonical text +// form of a 64-bit state hash (M1-DET-03's world.state_hash). +// The hash algorithm is NOT part of the contract: detcheck +// compares the lines byte-for-byte, so only run-to-run identity +// matters. +// - One line separator per line; a trailing newline on the final +// line is optional (an optional trailing \r is tolerated — +// Windows CRLF). +// stderr: ignored by detcheck (it remains visible in the CI job log). +// exit code: 0 when the scenario completes; any other value is a +// scenario failure. +// +// detcheck enforces the contract strictly (CORE-008: a malformed +// scenario is a loud exit-2 error, never a silent mismatch) and bounds +// the output (kMaxTicks lines, kMaxLineBytes per line — a scenario that +// runs away is a broken harness, not a determinism result). +// +// ============================================================================ +// Usage +// ============================================================================ +// +// laige-detcheck --scenario= [--ticks=N] [--seed=HEX|DEC] +// laige-detcheck --run-a= --run-b= +// [-- scenario-args...] +// +// Mode 1 (--scenario): a built-in scenario (M0 stand-in for the M1 +// scenarios): +// +// synthetic 32 fpx16_16 bodies + seeded Prng input, run +// twice in-process (two identical configurations) +// synthetic-perturbed the same, but run-b adds 1 unit to body 3's x +// at tick 7 — the perturbation fixture that +// proves the failure path (the step's Verify +// clause) +// +// Mode 2 (--run-a/--run-b): the real mode from M1-DET-04 on — two builds +// of the same scenario source (two build configurations) are executed +// and their hash streams compared. Everything after a `--` separator is +// passed to both scenario binaries (scenario arguments that could look +// like tool flags are unambiguous because of the separator). +// +// Report (stdout, stable and machine-greppable — LOG-001): +// +// match: +// detcheck scenario= result=OK ticks= +// run-a: +// run-b: +// divergence: +// detcheck scenario= result=DIVERGED first_diff_tick= +// run-a: +// run-b: +// (when one stream ends early the pair of tick lines becomes a +// stream-length note instead) +// +// Exit codes: +// 0 the two runs agree on every tick (deterministic — the OK result) +// 1 divergence detected (a determinism failure — loud, CORE-008) +// 2 usage error, unknown scenario, a scenario run failed (non-zero +// exit or spawn failure), or a scenario violated the output +// contract (malformed line / tick gap / unbounded output) +// +// ============================================================================ +// Built-in synthetic workload +// ============================================================================ +// +// 32 bodies of Q16.16 position/velocity (the default deterministic +// backend, M0-CORE-04 / ADR 0002). Each tick: fixed-order integration +// (x += vx, y += vy), wrap into a 64-unit box, then one seeded input +// event — the Prng (M0-CORE-06) picks the body index and a nudge in +// [-4, 3] added to its x. The synthetic-perturbed run-b additionally +// adds 1 unit to body 3's x at tick kPerturbTick (7). +// +// Per-tick hash: FNV-1a 64 (the same constants as the math_fixed +// known-answer test) over (tick, seed, every body's four raw words), +// big-endian per word (endianness-independent). M0 hash scope +// (documented, ARCH-010): tick counter + seed + body words. The Prng +// position is a pure function of (seed, nudge history) in this +// workload; the definitive scope — including PRNG state and the exact +// hash function — is defined by M1-DET-03's world.state_hash. +// +// Determinism scope of the built-in scenario: pure unsigned-integer +// arithmetic (fpx16_16 ops + xorshift128+) — bit-exact across build, +// platform, ISA, and compiler (ADR 0002; the language standard +// guarantees it). No float anywhere in the workload. +// +// Performance (this is a CI tool, not a hot path): one scenario run is +// O(ticks × kBodies); the two captured streams are bounded by +// kMaxTicks lines of ≤ kMaxLineBytes each (~1.5 MiB worst case). + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "laige/fpx16_16.h" +#include "laige/prng.h" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#endif + +namespace { + +// --- Named constants (CORE-005) ------------------------------------------ + +constexpr int kDefaultTicks = 256; // built-in scenario default run length +constexpr int kMaxTicks = 65536; // bounded scenario output (CORE-008) +constexpr std::uint64_t kDefaultSeed = 0x1DE7C0DEull; // "detcheck" seed +constexpr int kBodies = 32; // built-in scenario body count +constexpr int kPerturbTick = 7; // synthetic-perturbed: run-b perturbation +constexpr int kBoxUnits = 64; // the built-in world's wrap box (units) +constexpr std::size_t kMaxLineBytes = 64; // one hash line is ~27 bytes +constexpr std::uint64_t kFnvOffsetBasis = 0xcbf29ce484222325ull; +constexpr std::uint64_t kFnvPrime = 0x100000001b3ull; + +// The build type is stamped by CMake (tools/detcheck/CMakeLists.txt) so +// the report records which configuration produced this run (AGENTS 12). +#ifndef LAIGE_DETCHECK_BUILD_TYPE +#define LAIGE_DETCHECK_BUILD_TYPE "unknown" +#endif + +// --- Hash-line format (the scenario contract) ---------------------------- + +std::string formatHashLine(std::uint64_t tick, std::uint64_t hash) { + char buf[kMaxLineBytes]; + std::snprintf(buf, sizeof buf, "%llu %016llx", + static_cast(tick), + static_cast(hash)); + return buf; +} + +// Strict parse of one contract line: ' ' <16 lowercase hex>. +// Leading zeros are rejected (the contract forbids padding). +bool parseHashLine(const std::string& line, std::uint64_t& tick, + std::uint64_t& hash) { + const auto sp = line.find(' '); + if (sp == std::string::npos) return false; + if (sp == 0) return false; + tick = 0; + for (std::size_t i = 0; i < sp; ++i) { + const char c = line[i]; + if (c < '0' || c > '9') return false; + // Bounded parse: ticks never exceed kMaxTicks in a contract-valid + // stream, so this cannot overflow (CPP-004). + tick = tick * 10 + std::uint64_t(c - '0'); + } + if (line[0] == '0' && sp > 1) return false; // leading zero = padding + const std::size_t hexLen = line.size() - sp - 1; + if (hexLen != 16) return false; + hash = 0; + for (std::size_t i = 0; i < 16; ++i) { + const char c = line[sp + 1 + i]; + int digit; + if (c >= '0' && c <= '9') { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = c - 'a' + 10; + } else { + return false; + } + hash = hash * 16 + std::uint64_t(digit); + } + return true; +} + +// Full-stream contract check: line count ≤ maxTicks, every line well +// formed, and the tick of line i is exactly i (start at 0, step 1, no +// duplicates). Returns "" when valid, otherwise the error message. +std::string validateStream(const std::vector& lines, + std::size_t maxTicks) { + if (lines.empty()) return "no ticks emitted"; + if (lines.size() > maxTicks) { + return "more than " + std::to_string(maxTicks) + + " ticks emitted (unbounded scenario output)"; + } + for (std::size_t i = 0; i < lines.size(); ++i) { + std::uint64_t tick = 0; + std::uint64_t hash = 0; + if (!parseHashLine(lines[i], tick, hash)) { + return "malformed line " + std::to_string(i + 1) + ": '" + lines[i] + + "' (expected ' <16 lowercase hex digits>')"; + } + if (tick != i) { + return "tick sequence violation at line " + std::to_string(i + 1) + + ": tick " + std::to_string(tick) + " (expected " + + std::to_string(i) + ")"; + } + } + return ""; +} + +// --- Scenario execution (two build configurations) ------------------------ + +struct RunResult { + bool ok = false; // process ran to completion without an error + int exitCode = 0; + std::vector lines; + std::string error; // non-empty -> report + exit 2 +}; + +// Append raw stdout bytes to the line vector: split on '\n', strip one +// optional trailing '\r' (Windows CRLF), and enforce the per-line and +// per-stream bounds. Returns false when the output is unbounded. +bool appendChunk(std::vector& lines, std::string& pending, + const char* data, std::size_t n, std::size_t maxTicks) { + for (std::size_t i = 0; i < n; ++i) { + const char c = data[i]; + if (c == '\n') { + if (!pending.empty() && pending.back() == '\r') pending.pop_back(); + if (pending.size() > kMaxLineBytes) return false; + lines.push_back(std::move(pending)); + pending.clear(); + if (lines.size() > maxTicks) return false; + } else { + pending.push_back(c); + if (pending.size() > kMaxLineBytes) return false; + } + } + return true; +} + +// The final unterminated line (the trailing-newline-optional contract). +void finishPending(std::vector& lines, std::string& pending, + RunResult& r) { + if (pending.empty()) return; + if (pending.back() == '\r') pending.pop_back(); + if (pending.size() > kMaxLineBytes || lines.size() > kMaxTicks) { + if (r.error.empty()) { + r.error = "scenario output is unbounded (line or tick count exceeds " + "the contract)"; + } + return; + } + lines.push_back(std::move(pending)); +} + +#if defined(_WIN32) + +std::wstring toWide(std::string_view s) { + if (s.empty()) return std::wstring(); + const int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), + static_cast(s.size()), nullptr, 0); + std::wstring w(static_cast(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), + w.data(), n); + return w; +} + +// CreateProcessW command-line quoting (Microsoft quoting rules): quote +// an argument that contains whitespace or a double quote; double the +// quotes inside. +std::wstring quoteArg(std::string_view arg) { + if (arg.find_first_of(" \t\"") == std::string_view::npos) { + return toWide(arg); + } + std::wstring q = L"\""; + for (const char c : arg) { + if (c == '"') { + q += L"\"\""; + } else { + q += static_cast(static_cast(c)); + } + } + q += L"\""; + return q; +} + +RunResult runScenario(const std::string& exe, + const std::vector& args) { + RunResult r; + std::wstring cmd = toWide(exe); + for (const std::string& a : args) cmd += L" " + quoteArg(a); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof sa; + HANDLE readH = INVALID_HANDLE_VALUE; + HANDLE writeH = INVALID_HANDLE_VALUE; + if (!CreatePipe(&readH, &writeH, &sa, 0)) { + r.error = "CreatePipe failed"; + return r; + } + // The child inherits the write end of the pipe. + if (!SetHandleInformation(writeH, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) { + r.error = "SetHandleInformation failed"; + CloseHandle(readH); + CloseHandle(writeH); + return r; + } + STARTUPINFOW si{}; + si.cb = sizeof si; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeH; // capture stdout + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); // stays visible in the log + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + PROCESS_INFORMATION pi{}; + if (!CreateProcessW(nullptr, cmd.data(), nullptr, nullptr, TRUE, 0, nullptr, + nullptr, &si, &pi)) { + r.error = "CreateProcessW failed (is the path correct?)"; + CloseHandle(readH); + CloseHandle(writeH); + return r; + } + CloseHandle(writeH); + + char buf[65536]; + std::string pending; + for (;;) { + DWORD n = 0; + if (!PeekNamedPipe(readH, buf, sizeof buf, &n, nullptr, nullptr)) { + r.error = "PeekNamedPipe failed"; + break; + } + if (n == 0) break; // the scenario closed the pipe + if (n > sizeof buf) n = sizeof buf; + DWORD got = 0; + if (!ReadFile(readH, buf, n, &got, nullptr)) { + r.error = "ReadFile failed"; + break; + } + if (!appendChunk(r.lines, pending, buf, got, kMaxTicks)) { + r.error = "scenario output is unbounded (line or tick count exceeds " + "the contract)"; + break; + } + } + CloseHandle(readH); + DWORD code = 0; + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + r.exitCode = static_cast(code); + finishPending(r.lines, pending, r); + if (r.exitCode != 0 && r.error.empty()) { + r.error = "scenario process exited with code " + std::to_string(code); + } + r.ok = r.error.empty(); + return r; +} + +#else // POSIX (Linux, macOS) + +RunResult runScenario(const std::string& exe, + const std::vector& args) { + RunResult r; + int pipefd[2]; + if (pipe(pipefd) != 0) { + r.error = "pipe() failed"; + return r; + } + const pid_t pid = fork(); + if (pid < 0) { + r.error = "fork() failed"; + ::close(pipefd[0]); + ::close(pipefd[1]); + return r; + } + if (pid == 0) { + // Child: stdout goes to the pipe; stderr is inherited, so a scenario + // crash report still reaches the CI log. + if (::dup2(pipefd[1], 1) < 0) _exit(127); + ::close(pipefd[0]); + ::close(pipefd[1]); + std::vector argv; + argv.push_back(const_cast(exe.c_str())); + for (const std::string& a : args) argv.push_back(const_cast(a.c_str())); + argv.push_back(nullptr); + execv(exe.c_str(), argv.data()); + _exit(127); // execv failed: the path is wrong or not executable + } + ::close(pipefd[1]); + char buf[65536]; + std::string pending; + for (;;) { + const ssize_t n = ::read(pipefd[0], buf, sizeof buf); + if (n < 0) { + if (errno == EINTR) continue; + r.error = "read() of the scenario stdout failed"; + break; + } + if (n == 0) break; // EOF: the scenario finished + if (!appendChunk(r.lines, pending, buf, static_cast(n), + kMaxTicks)) { + r.error = "scenario output is unbounded (line or tick count exceeds " + "the contract)"; + break; + } + } + ::close(pipefd[0]); + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + r.error = "waitpid() failed"; + } + if (WIFEXITED(status)) { + r.exitCode = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + r.exitCode = 128 + WTERMSIG(status); + if (r.error.empty()) { + r.error = "scenario process was killed by signal " + + std::to_string(WTERMSIG(status)); + } + } + finishPending(r.lines, pending, r); + if (r.exitCode != 0 && r.error.empty()) { + r.error = "scenario process exited with code " + std::to_string(r.exitCode); + } + r.ok = r.error.empty(); + return r; +} + +#endif // _WIN32 + +// --- The built-in synthetic scenario (M0 stand-in) ------------------------- + +struct SyntheticBody { + laige::fpx16_16 x{}; + laige::fpx16_16 y{}; + laige::fpx16_16 vx{}; + laige::fpx16_16 vy{}; +}; +using SyntheticWorld = std::array; + +void initWorld(SyntheticWorld& w, laige::Prng& prng) { + for (int i = 0; i < kBodies; ++i) { + // Deterministic seed layout: body i starts at (8*(i/8), 8*(i%8)) — + // an 8x8 grid spanning the whole 64-unit box, so both wrap branches + // (x and y, low and high) are exercised within the default tick + // budget — with a seeded velocity in [-2, 2]. + w[i].x = laige::fpx16_16::fromInt32((i / 8) * 8); + w[i].y = laige::fpx16_16::fromInt32((i % 8) * 8); + const int dvx = static_cast(prng.next_range(0, 5)) - 2; + const int dvy = static_cast(prng.next_range(0, 5)) - 2; + w[i].vx = laige::fpx16_16::fromInt32(dvx); + w[i].vy = laige::fpx16_16::fromInt32(dvy); + } +} + +// One tick: fixed-order integration (PRD 10.3: deterministic iteration +// order), wrap into the 64-unit box (one wrap per axis per tick is +// enough: |v| ≤ 2), then one seeded input event (the Prng picks the +// body index and a nudge in [-4, 3] applied to x). +void stepWorld(SyntheticWorld& w, laige::Prng& prng) { + using laige::fpx16_16; + const fpx16_16 bound = fpx16_16::fromInt32(kBoxUnits); + for (SyntheticBody& b : w) { + b.x = fpx16_16::add(b.x, b.vx); + b.y = fpx16_16::add(b.y, b.vy); + if (b.x >= bound) { + b.x = fpx16_16::sub(b.x, bound); + } else if (b.x < fpx16_16{}) { + b.x = fpx16_16::add(b.x, bound); + } + if (b.y >= bound) { + b.y = fpx16_16::sub(b.y, bound); + } else if (b.y < fpx16_16{}) { + b.y = fpx16_16::add(b.y, bound); + } + } + const int i = static_cast(prng.next_range(0, kBodies)); + const int nudge = static_cast(prng.next_range(0, 8)) - 4; + w[i].x = fpx16_16::add(w[i].x, fpx16_16::fromInt32(nudge)); +} + +// FNV-1a 64 over (tick, seed, every body's four raw words); big-endian +// per word (endianness-independent) — the same constants as the +// math_fixed known-answer test (house FNV choice). +std::uint64_t hashWorld(std::uint64_t tick, std::uint64_t seed, + const SyntheticWorld& w) { + std::uint64_t h = kFnvOffsetBasis; + auto feed = [&h](std::uint64_t v) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (v >> shift) & 0xFFull; + h *= kFnvPrime; + } + }; + feed(tick); + feed(seed); + for (const SyntheticBody& b : w) { + feed(static_cast(static_cast(b.x.raw))); + feed(static_cast(static_cast(b.y.raw))); + feed(static_cast(static_cast(b.vx.raw))); + feed(static_cast(static_cast(b.vy.raw))); + } + return h; +} + +struct RunSpec { + std::string label; + int ticks; + std::uint64_t seed; + bool perturb; // run-b of synthetic-perturbed: the +1 nudge at tick 7 +}; + +// One built-in run: the state after processing tick t is hashed and +// emitted as the line for tick t (the first line is tick 0). +std::vector runBuiltIn(const RunSpec& spec) { + laige::Prng prng(spec.seed); + SyntheticWorld w; + initWorld(w, prng); + std::vector lines; + lines.reserve(static_cast(spec.ticks)); + for (int t = 0; t < spec.ticks; ++t) { + stepWorld(w, prng); + if (spec.perturb && t == kPerturbTick) { + w[3].x = laige::fpx16_16::add(w[3].x, laige::fpx16_16::fromInt32(1)); + } + lines.push_back(formatHashLine(static_cast(t), + hashWorld(static_cast(t), + spec.seed, w))); + } + return lines; +} + +std::string formatSeed(std::uint64_t seed) { + char buf[32]; + std::snprintf(buf, sizeof buf, "0x%llx", + static_cast(seed)); + return buf; +} + +// --- Comparison and reporting ---------------------------------------------- + +struct CompareResult { + bool ok = false; + std::size_t firstDiffTick = 0; + std::string lineA; + std::string lineB; +}; + +CompareResult compareStreams(const std::vector& a, + const std::vector& b) { + CompareResult c; + const std::size_t n = std::min(a.size(), b.size()); + for (std::size_t i = 0; i < n; ++i) { + if (a[i] != b[i]) { + c.ok = false; + c.firstDiffTick = i; + c.lineA = a[i]; + c.lineB = b[i]; + return c; + } + } + if (a.size() != b.size()) { + // One stream ended early: a divergence at the boundary (a truncated + // run is a determinism failure, not a match). + c.ok = false; + c.firstDiffTick = n; + c.lineA = "stream ends: " + std::to_string(a.size()) + " ticks"; + c.lineB = "stream ends: " + std::to_string(b.size()) + " ticks"; + return c; + } + c.ok = true; + return c; +} + +std::string basenameOf(std::string_view path) { + const auto pos = path.find_last_of("/\\"); + return pos == std::string_view::npos ? std::string(path) + : std::string(path.substr(pos + 1)); +} + +void report(std::string_view scenarioName, const CompareResult& c, + std::size_t ticks, std::string_view labelA, + std::string_view labelB) { + if (c.ok) { + std::printf("detcheck scenario=%s result=OK ticks=%llu\n", + std::string(scenarioName).c_str(), + static_cast(ticks)); + std::printf(" run-a: %s\n", std::string(labelA).c_str()); + std::printf(" run-b: %s\n", std::string(labelB).c_str()); + } else { + std::printf("detcheck scenario=%s result=DIVERGED first_diff_tick=%llu\n", + std::string(scenarioName).c_str(), + static_cast(c.firstDiffTick)); + std::printf(" run-a: %s\n", c.lineA.c_str()); + std::printf(" run-b: %s\n", c.lineB.c_str()); + } +} + +// --- Argument parsing ------------------------------------------------------ + +struct Args { + std::string scenario; // mode 1 + std::string runA; // mode 2 + std::string runB; // mode 2 + bool ticksSet = false; + int ticks = kDefaultTicks; + bool seedSet = false; + std::uint64_t seed = kDefaultSeed; + std::vector positionals; // mode 2 pass-through +}; + +void printUsage(std::FILE* out) { + std::fprintf( + out, + "usage: laige-detcheck --scenario= [--ticks=N] " + "[--seed=HEX|DEC]\n" + " laige-detcheck --run-a= --run-b=" + " [-- scenario-args...]\n" + " laige-detcheck --help\n" + "\n" + " --scenario built-in scenario: synthetic | " + "synthetic-perturbed\n" + " --run-a/--run-b two builds of the same scenario (two build\n" + " configurations)\n" + " --ticks built-in scenario tick count (1..%d, " + "default %d)\n" + " --seed built-in scenario seed (0xHEX or decimal)\n" + " -- end of tool options; the remaining args\n" + " are passed to both scenario binaries\n" + "\n" + "Exit codes: 0 = match, 1 = divergence, 2 = error (usage, unknown\n" + "scenario, scenario failure, contract violation).\n", + kMaxTicks, kDefaultTicks); +} + +bool parseArgs(int argc, char** argv, Args& a) { + bool passThrough = false; // everything after `--` is a scenario argument + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (passThrough) { + a.positionals.push_back(arg); + continue; + } + if (arg == "--") { + passThrough = true; + continue; + } + const auto eq = arg.find('='); + const bool hasValue = eq != std::string::npos && eq + 1 < arg.size(); + const std::string key = hasValue ? arg.substr(0, eq) : arg; + const std::string value = hasValue ? arg.substr(eq + 1) : ""; + + if (key == "--scenario") { + if (!hasValue) return false; + a.scenario = value; + } else if (key == "--run-a") { + if (!hasValue) return false; + a.runA = value; + } else if (key == "--run-b") { + if (!hasValue) return false; + a.runB = value; + } else if (key == "--ticks") { + if (!hasValue || value.empty()) return false; + std::uint64_t v = 0; + for (const char c : value) { + if (c < '0' || c > '9') return false; + if (v > (static_cast(kMaxTicks) - + static_cast(c - '0')) / 10) { + return false; // beyond the contract bound + } + v = v * 10 + std::uint64_t(c - '0'); + } + if (v < 1) return false; + a.ticksSet = true; + a.ticks = static_cast(v); + } else if (key == "--seed") { + if (!hasValue) return false; + a.seedSet = true; + a.seed = std::strtoull(value.c_str(), nullptr, 0); // 0x or decimal + } else if (key == "--help" || key == "-h") { + printUsage(stdout); + std::exit(0); + } else { + // Unknown option, or a bare token before `--`: scenario arguments + // must come after `--`, so this is always a usage error (strict + // surface, API-008). + return false; + } + } + return true; +} + +} // namespace + +int main(int argc, char** argv) { + Args a; + if (!parseArgs(argc, argv, a)) { + printUsage(stderr); + return 2; + } + const bool mode1 = !a.scenario.empty(); + const bool mode2 = !a.runA.empty() || !a.runB.empty(); + if (mode1 && mode2) { + std::fprintf(stderr, + "laige-detcheck: --scenario and --run-a/--run-b are " + "mutually exclusive\n"); + printUsage(stderr); + return 2; + } + if (mode1 && !a.positionals.empty()) { + std::fprintf(stderr, + "laige-detcheck: scenario args (after --) are only " + "allowed with --run-a/--run-b\n"); + printUsage(stderr); + return 2; + } + if (!mode1 && (a.ticksSet || a.seedSet)) { + std::fprintf(stderr, + "laige-detcheck: --ticks/--seed apply to the built-in " + "scenario only\n"); + printUsage(stderr); + return 2; + } + if (!mode1 && !mode2) { + printUsage(stderr); + return 2; + } + if (mode2 && (a.runA.empty() || a.runB.empty())) { + std::fprintf(stderr, + "laige-detcheck: both --run-a and --run-b are required\n"); + printUsage(stderr); + return 2; + } + if (mode1 && a.scenario != "synthetic" && a.scenario != "synthetic-perturbed") { + std::fprintf(stderr, + "laige-detcheck: unknown scenario '%s' (built-in: " + "synthetic, synthetic-perturbed)\n", + a.scenario.c_str()); + return 2; + } + + // --- Mode 2: two scenario binaries (two build configurations) ---------- + if (mode2) { + const RunResult resA = runScenario(a.runA, a.positionals); + if (!resA.ok) { + std::fprintf(stderr, "laige-detcheck: scenario run-a: %s\n", + resA.error.c_str()); + return 2; + } + const std::string errA = validateStream(resA.lines, kMaxTicks); + if (!errA.empty()) { + std::fprintf(stderr, "laige-detcheck: scenario run-a: %s\n", + errA.c_str()); + return 2; + } + const RunResult resB = runScenario(a.runB, a.positionals); + if (!resB.ok) { + std::fprintf(stderr, "laige-detcheck: scenario run-b: %s\n", + resB.error.c_str()); + return 2; + } + const std::string errB = validateStream(resB.lines, kMaxTicks); + if (!errB.empty()) { + std::fprintf(stderr, "laige-detcheck: scenario run-b: %s\n", + errB.c_str()); + return 2; + } + const CompareResult c = compareStreams(resA.lines, resB.lines); + report(basenameOf(a.runA) + " vs " + basenameOf(a.runB), c, + resA.lines.size(), a.runA, a.runB); + return c.ok ? 0 : 1; + } + + // --- Mode 1: built-in scenario, two in-process runs -------------------- + const RunSpec specA{ + std::string(a.scenario) + "[seed=" + formatSeed(a.seed) + + " ticks=" + std::to_string(a.ticks) + + " build=" + LAIGE_DETCHECK_BUILD_TYPE + "]", + a.ticks, a.seed, false}; + const RunSpec specB{ + std::string(a.scenario) + "[seed=" + formatSeed(a.seed) + + " ticks=" + std::to_string(a.ticks) + + " build=" + LAIGE_DETCHECK_BUILD_TYPE + + (a.scenario == "synthetic-perturbed" + ? " perturb_tick=" + std::to_string(kPerturbTick) + : "") + + "]", + a.ticks, a.seed, a.scenario == "synthetic-perturbed"}; + const std::vector linesA = runBuiltIn(specA); + const std::vector linesB = runBuiltIn(specB); + const CompareResult c = compareStreams(linesA, linesB); + report(a.scenario, c, linesA.size(), specA.label, specB.label); + return c.ok ? 0 : 1; +}