diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fe4fc9f..f946959b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,21 @@ env: # cache automatically. The build.zig minor-version guard still enforces the # 0.16.x invariant on the local toolchain. ZIG_VERSION: "0.16.0" + # M1.1.14 — the third pinning axis of `ARCH-031` rule 6, alongside the + # compiler version above and the per-cell optimize mode. NO CELL COMPILES FOR + # ITS NATIVE CPU, and the motive is not parity between two OSes — two + # IEEE-strict builds agree anyway — it is stability IN TIME: a runner image + # that moves to a new processor generation would otherwise invalidate a + # committed determinism witness without a line of code having changed, and the + # diagnosis would go looking inside the engine. + # + # `baseline` rather than a named model or `x86_64_v2`: it is defined per + # architecture by the compiler, so the single value below is correct on + # x86_64 and on aarch64 alike, and it is the one choice that cannot drift with + # a runner refresh. The cost is SSE2-only codegen on x86_64; the CI legs are + # dominated by compilation rather than by test arithmetic, and determinism is + # not negotiable per cell (`ARCH-031`, Conséquences). + ZIG_CPU: "baseline" jobs: # M1.0.3-followup — doc-only fast path. `ci-gate` (en fin de fichier) devient @@ -85,8 +100,44 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-2025] + # M1.1.14 / Gate D — `ubuntu-24.04-arm` carries the half of C1.1 that no + # machine in rotation can measure: two physical machines, three GPU + # configurations, no ARM64 among them. Level 2 point 1 — the four discrete + # traces identical to the x86_64 witness over 60 frames, at both precisions + # — is the PASS/FAIL, and it is verified by the `forge-determinism` step + # below like any other cell. + # + # A CONTINUOUS DIVERGENCE HERE IS NOT A FAILURE, and the point is worth + # writing where the cell is declared rather than only in the brief: level 2 + # PREDICTS it. `engine-phase-1-criteria.md` C1.1 puts inter-ISA + # bit-exactness at level 3 and explicitly out of Phase 1, so the chain + # comparison skips on this ISA by design and prints that it did. What the + # cell must produce is the discrete parity and a divergence frame that is + # reproducible run to run; the frame's REGRESSION is the signal, never its + # value. + # + # This is the first time the repository compiles for AArch64 in CI. What + # falls here and is NOT the discrete parity is a finding to measure and + # record, not to fix under cover of this milestone. + os: [ubuntu-24.04, windows-2025, ubuntu-24.04-arm] mode: [Debug, ReleaseSafe] + # M1.1.14 / Gate E — THE PRECISION AXIS, `engine-platform.md` §8's third + # dimension, built rather than amended away. `-Dphysics_f64` is a supported + # build flag, and a supported flag no cell exercises is an untested + # configuration — the milestone's own named prohibition. + # + # AFFORDABLE, ON THE NUMBER AND NOT ON THE INTENTION. The cache returned to + # `windows-2025 / Debug` took it from 14 minutes to 4 of a 20-minute budget; + # every other cell sits between 1 and 9 minutes of its own. The one cell + # without margin was `ubuntu-24.04-arm / ReleaseSafe` at 25 of 55, and its + # cost was LOCATED before this axis was allowed to double it: 129 of 132 + # `compile test` steps ran where a good run reuses 131, so the cost is + # COMPILATION and not test execution. Compilation does not depend on the + # precision — `-Dphysics_f64` moves a comptime constant, not a volume of + # code — so the f64 twin costs the same, and 25 minutes is a cache-miss + # figure rather than a steady state: the same cell ran in 1 minute when the + # cache served it. + precision: [false, true] runs-on: ${{ matrix.os }} # M0.1 hotfix — bumped from 10 to 20 min: Windows ReleaseSafe on the # 2-vCPU runner spends ~3 min on `zig build` then ~7 min on @@ -181,16 +232,52 @@ jobs: # save cost exceeded the build it protected and pushed the job past its # ceiling. The Debug legs now run fully cold by design, so their build and # test wall-times are the honest cold numbers rather than a cache lottery. + # M1.1.14 — `ZIG_CPU` JOINS EVERY CACHE KEY, and this is a READING of the + # config rather than an experiment. The keys carried os, mode and Zig + # version but NOT the CPU axis this milestone introduced, so a + # `-Dcpu=baseline` build could restore a cache saved from a NATIVE-CPU + # build through either restore-key. Zig's manifests are cpu-aware, so that + # never returns a wrong object — it returns a MISS and rebuilds, leaving + # BOTH variants of everything in one `.zig-cache`. + # + # That bloat is what meets the 2 GB save cap recorded above + # (`was 6214569092; purged contents before save`), and the purge is the + # corruption mechanism: it deletes objects while the manifests referencing + # them survive into the saved archive, so the next restore hands + # `zig build` a manifest whose entry cannot be stat'd — `file_hash + # FileNotFound` on an executable the build believes it has. + # + # Every measured property of that failure follows: `ReleaseSafe` ONLY, + # because these cache steps are gated on that mode and Debug has none; + # `windows-2025` only, where the purge was observed; content varying at + # CONSTANT COMMIT, because which entries were purged depends on the save; + # and one commit green then red a day apart, because it depends on which + # generation was restored. + # + # Adding the axis fixes the `ARCH-031` rule 6 pinning AND makes every + # pre-M1.1.14 cache unrestorable — which is the point: those are the + # mixed-CPU archives that feed the purge. + # M1.1.14 / Gate E — THE RELEASESAFE-ONLY RESTRICTION IS LIFTED, because the + # measurement it rested on has been refuted. It was adopted when + # `Save Zig cache` went 39s -> 5m23 -> 7m39 and ate the Debug budget; BOTH + # causes of that inflation are now fixed — the key carried no CPU axis, so + # every run mixed two CPU variants into one archive, and the oversized result + # met a cap that purged it. A decision resting on a refuted measurement is + # not inherited, it is re-measured. + # + # `windows-2025 / Debug` is the cell that matters: 14 minutes of a 20-minute + # budget with no cache, against 5 of 55 for its ReleaseSafe sibling, which now + # builds in 2 seconds. It is also the cell a `{f32, f64}` axis would double, + # so this is the number that decides that axis. - name: Restore Zig cache - if: matrix.mode == 'ReleaseSafe' id: zig-cache uses: actions/cache/restore@v5 with: path: .zig-cache - key: zig-${{ matrix.os }}-${{ matrix.mode }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + key: zig-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} restore-keys: | - zig-${{ matrix.os }}-${{ matrix.mode }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- - zig-${{ matrix.os }}-${{ matrix.mode }}-${{ env.ZIG_VERSION }}- + zig-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}- + zig-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}- - name: zig fmt --check run: zig fmt --check src tests build.zig @@ -206,7 +293,7 @@ jobs: run: | set -euo pipefail s=$SECONDS - zig build -Doptimize=${{ matrix.mode }} + zig build -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} echo "BUILD_SECONDS=$((SECONDS - s))" >> "$GITHUB_ENV" # CI cache refresh chore — mid-job save right after `zig build`: a run @@ -218,25 +305,120 @@ jobs: # distinct from the full post-test save at the end of the job; the # zon-level prefix fallback of the restore step matches both, newest # first. + # M1.1.14 — THE SAVE IS ALL-OR-NOTHING. `actions/cache` purges contents when + # the archive exceeds its cap and saves what is left, which produces a + # `.zig-cache` whose manifests reference objects that are no longer there: + # the next restore hands `zig build` an entry it cannot stat, and the build + # fails with `file_hash FileNotFound` on something it believes it has. + # A cache that LIES costs more than no cache — this milestone spent six runs + # and two wrong hypotheses learning it. + # + # So the size is measured first and the save is SKIPPED, loudly, when it + # would be partial. A skipped save means the next run is cold: slower, and + # correct. Pinning `ZIG_CPU` in the key removed the cause that was inflating + # the archive here, but the cap will be met again for other reasons — the f64 + # axis and the ARM cell both add targets — and the purge would corrupt just + # the same. The trigger was fixed; this fixes the mechanism. + # M1.1.14 — THE 2 GB CONSTANT HAD NO SOURCE ON THIS CACHE, and both ReleaseSafe + # cells have been permanently cold because of it. Measured, not argued: + # + # * the line `Zig cache exceeded 2147483648 bytes (was …); purged contents + # before save` is emitted by `Post Run weldengine/setup-zig@v0.1.0` — OUR + # OWN action, in its post step, about the cache IT manages. It is not + # `actions/cache`, and it is not this `.zig-cache`. + # * `2147483648` occurs exactly TWICE in all of `.github/`: in the comment + # quoting that message, and as the `limit=` below. It was transcribed + # from one cache's cap onto a different cache. + # * the volumes that action reports on the GLOBAL cache are far larger than + # anything here — 14 700 387 106 bytes on ubuntu and 7 707 924 893 on + # windows in bench run 31933791179 — so the two are not even the same + # order of quantity. + # + # THIS RUN IS AN EXPERIMENT AND ITS LOG IS THE RESULT. The pre-check is + # raised to GitHub's documented repository-wide 10 GB so it stops pre-empting, + # and `actions/cache/save` is allowed to answer for itself. Two outcomes, both + # readable in the log and neither assumed here: + # + # * it SAVES — then the cells stop being cold, and the next run yields the + # warm `windows-2025 / ReleaseSafe` figure that decides the f64 matrix + # axis. That number does not exist today. + # * it REFUSES, with its own "over the … limit, not saving cache" — then + # refusing is safe, the cells are cold for a reason that is finally the + # platform's rather than ours, and the decision moves to what is cached. + # + # The outcome that would be a defect is a PURGE — a partial archive whose + # manifests outlive their objects, which the next restore turns into + # `file_hash FileNotFound`. Its signature is known and recorded: ReleaseSafe + # only, windows-2025 only, content varying at a CONSTANT commit. If it + # appears, this raise is reverted; a cache that lies costs more than no cache. + - name: Measure the Zig cache before saving + id: cache-size + shell: bash + run: | + set -euo pipefail + limit=10737418240 + bytes=$(du -sk .zig-cache 2>/dev/null | cut -f1 || echo 0) + bytes=$((bytes * 1024)) + echo "zig cache: $bytes bytes (cap $limit)" + # The decomposition, so the "reduce what is cached" route is instructed by + # a measurement rather than by a guess. Measured locally, `o` is ~99 % of + # the tree — and it cannot be dropped, since a manifest without its object + # is the corruption above. + du -sk .zig-cache/* 2>/dev/null | sort -rn | head -5 || true + if [ "$bytes" -gt "$limit" ]; then + echo "::warning::Zig cache is $bytes bytes, over the $limit cap — SKIPPING the save." + echo "::warning::A partial save leaves manifests referencing purged objects, which" + echo "::warning::the next restore turns into 'file_hash FileNotFound'. Cold is correct." + echo "save=false" >> "$GITHUB_OUTPUT" + else + echo "save=true" >> "$GITHUB_OUTPUT" + fi + - name: Save Zig cache (post-build) - if: matrix.mode == 'ReleaseSafe' + if: steps.cache-size.outputs.save == 'true' uses: actions/cache/save@v5 with: path: .zig-cache - key: zig-${{ matrix.os }}-${{ matrix.mode }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }}-build + key: zig-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }}-build - name: zig build test shell: bash run: | set -euo pipefail s=$SECONDS - zig build test -Doptimize=${{ matrix.mode }} + # The suite's own COLLECTED TOTAL is captured here and handed to the + # dead-test conservation below. M1.1.14 review — that control existed + # behind an optional `--expect-collected=N` which NOTHING passed, so the + # tool printed an expectation and then printed `clean`, having compared it + # to nothing. The tool now checks a DECLARED total unconditionally; this + # step supplies the second, INDEPENDENT number, which is what stops the + # declared one from being bumped to match a drifted closure. + # + # `tee` and not a re-run: the number must come from the invocation that + # actually ran the tests. And the exit code is captured through + # PIPESTATUS, `set -euo pipefail` notwithstanding — a pipeline's status is + # the last command's, and this repository has pushed a red build under a + # green self-report exactly that way. + zig build test --summary all -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} 2>&1 | tee test-out.txt + rc=${PIPESTATUS[0]} + if [ "$rc" -ne 0 ]; then exit "$rc"; fi + # "N/M tests passed" — M is the collected total. Absent or unparsable is a + # FAILURE and not a skip: a missing number is how a control comes to be + # bypassed, which is the defect this whole step exists to close. + collected="$(sed -n 's/.*[0-9][0-9]* of \([0-9][0-9]*\) tests passed.*/\1/p;s#.*[^0-9]\([0-9][0-9]*\)/\([0-9][0-9]*\) tests passed.*#\2#p' test-out.txt | tail -1)" + if ! printf '%s' "$collected" | grep -Eq '^[0-9]+$'; then + echo "::error::could not read the collected test total from the suite summary" + grep -n "tests passed" test-out.txt || true + exit 1 + fi + echo "COLLECTED_TESTS=$collected" >> "$GITHUB_ENV" + echo "suite reported $collected collected tests" echo "TEST_SECONDS=$((SECONDS - s))" >> "$GITHUB_ENV" # M1.0.15 — Etch test-runner acceptance corpus: the Zig driver over the # `.etch` fixtures + the `etch_test` shim run over the green fixtures. - name: zig build test-etch - run: zig build test-etch -Doptimize=${{ matrix.mode }} + run: zig build test-etch -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} # M0.2 / E5 — bindgen-verify gate. Regenerates the Vulkan + # Wayland bindings and asserts `git diff --quiet` on @@ -244,7 +426,7 @@ jobs: # between the committed bindings and what the regen produces # blocks the merge. - name: zig build bindgen-verify - run: zig build bindgen-verify + run: zig build bindgen-verify -Dcpu=${{ env.ZIG_CPU }} # M0.8 / E3-D — D-S5-synth100-proper proof: the standalone # bench/fixtures/synth_100/ sub-project resolves the parent as a @@ -253,7 +435,92 @@ jobs: # A nested cold build (~30 s) — one matrix leg is enough. - name: zig build verify-synth-100 if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' - run: zig build verify-synth-100 + run: zig build verify-synth-100 -Dcpu=${{ env.ZIG_CPU }} + + # M1.1.14 — the conformance test of `ARCH-031` rule 4, read in the emitted + # assembly and not in the source: `forge_3d` is compiled for the three + # targets the engine ships and every call site is inspected for a libm + # transcendental. It answers a property of those three TARGETS, so one + # cell covers the matrix — same arbitration as `verify-synth-100` above, + # and the reason it is a dedicated step rather than part of `zig build + # test` (which the pre-push hook runs twice on every push) is written in + # `build.zig`. Not gated on the matrix mode of the cell: the step pins + # ReleaseSafe internally, since that is the mode a witness is produced in. + - name: zig build forge-asm-inventory + if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' + run: zig build forge-asm-inventory -Dcpu=${{ env.ZIG_CPU }} + + # M1.1.14 — `zig build lint` ON CI, and its absence was the ninth instance of + # this milestone's own failure mode. A grep over the whole of + # `.github/workflows/` returned NOTHING: the only caller was the `pre-commit` + # hook in `lefthook.yml`. So the brief lists "`zig build lint` green" as a CI + # criterion that no cell could produce, and — worse — BOTH guards this + # milestone built hang off this step: the dead-test closure and + # `no_float_reduce`. Neither ran in CI. A doctrine written on the strength of + # a guard nobody runs is the prohibition this milestone named against itself, + # and the local counter-factual that authorised it proved only that the + # LOCAL invocation reddens. + # + # One cell, because it answers a property of the SOURCE and not of the host — + # the same arbitration as `verify-synth-100` and `forge-asm-inventory` above. + - name: zig build lint + if: matrix.os == 'ubuntu-24.04' && matrix.mode == 'Debug' + run: zig build lint -Dcpu=${{ env.ZIG_CPU }} + + # M1.1.14 review — THE SUITE-DERIVED HALF OF THE CONSERVATION, on the cell. + # `zig build lint` above already runs the unconditional check of the closure + # against the DECLARED total. This confronts that declared number with the + # total the suite itself reported in this same job, captured from the run that + # actually executed the tests. Two independently produced numbers, which is + # what the bilateral control has meant since it was written — and what was + # missing while `--expect-collected` sat behind a flag nobody passed. + # + # EVERY CELL, and NOT the one cell `lint` runs on — which was the first version + # of this step and left a hole the moment it went green. The lint RULE pass is + # one cell because it answers a property of the SOURCE; the conservation is not + # that kind of quantity. The collected total is PER PLATFORM — 1864 on Windows + # against 1866 elsewhere, the two `only_on = .windows` entries of `uncollected` — + # so a control on ubuntu alone leaves `expectedCollectedOn(.windows)` a declared + # number that nothing confronts, which is the very shape P2-7 exists to remove. + # + # `COLLECTED_TESTS` is set by the test step above and its absence fails there, so + # an empty value cannot reach this line silently. The step is a source scan plus + # one small build, so paying it twelve times is cheap against a number per + # platform that would otherwise be unchecked on two of the three. + - name: dead-test conservation against the suite's own total + # `shell: bash` DECLARED, and the deliberate red is what found the need. The + # step carried no `shell:`, so on windows-2025 it ran under `pwsh`. The plain + # form is a bare substitution and worked there — the green run printed + # `control OK at 1864` — but the counter-factual's `$(( N + 1 ))` became + # `$ 1865` under PowerShell, two tokens, and `zig build` answered + # `Expected -Dexpect-collected to be an integer of type usize`. So the + # Windows red proved an argument-parsing failure and NOT the conservation + # firing, and the negative witness on Windows is not established. + # Uniform across the twelve now, which also removes the trap for whoever next + # adds shell syntax here. + shell: bash + run: zig build dead-tests -Dcpu=${{ env.ZIG_CPU }} -Dexpect-collected=${{ env.COLLECTED_TESTS }} + + # M1.1.14 — THE DETERMINISM HARNESS, ON THE NORMAL PATH AND NOT BEHIND A + # FLAG. Every cell replays the canonical scenario and compares its own output + # against the committed witnesses. This is where C1.1 level 1 is verified on + # x86_64 and level 2 point 1 on the ARM64 cell; a witness with nothing reading + # it is a file. + # + # NO AGGREGATOR, by the arbitration in `engine-development-workflow.md` §7.3: + # each cell compares against the in-tree witness and fails ALONE, so a failure + # names the platform instead of announcing that two blobs differ. + # + # RESTORED AFTER BEING DELETED BY ACCIDENT, and the accident is worth its + # line. The commit that removed the temporary `lint` counter-factual cut a + # span running from that step's comment to the timing report's, and THIS step + # sat between them. Three runs then went green with no determinism check at + # all — a step that does not run cannot fail — which is the very property the + # deleted counter-factual existed to prove about `lint`. Measured, not + # inferred: `git show :.github/workflows/ci.yml | grep -c` returns 1 at + # `9a31746` and `a18b408`, and 0 from `c3d6073` onward. + - name: zig build forge-determinism + run: zig build forge-determinism -Doptimize=${{ matrix.mode }} -Dphysics_f64=${{ matrix.precision }} -Dcpu=${{ env.ZIG_CPU }} # M0.9 / E1 — CI wall-time + cache measurement, archived as an # artifact (measurement deliverable, not a gate). `always()` so a @@ -270,14 +537,21 @@ jobs: shell: bash run: | set -euo pipefail - report="ci-timing-${{ matrix.os }}-${{ matrix.mode }}.txt" + report="ci-timing-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}.txt" { - echo "cell=build-and-test (${{ matrix.os }}, ${{ matrix.mode }})" + echo "cell=build-and-test (${{ matrix.os }}, ${{ matrix.mode }}, ${{ matrix.precision }})" echo "os=${{ matrix.os }}" echo "mode=${{ matrix.mode }}" + echo "physics_f64=${{ matrix.precision }}" echo "zig_version=${{ env.ZIG_VERSION }}" - echo "cache_key=zig-${{ matrix.os }}-${{ matrix.mode }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }}" - echo "cache_enabled=${{ matrix.mode == 'ReleaseSafe' }}" + echo "cache_key=zig-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }}" + echo "cache_enabled=true" + # M1.1.14 — the archive size travels with the timings, because the + # decision it feeds is about matrix SHAPE and a reader weighing another + # axis needs both. Measured here: a Debug archive is far smaller than a + # ReleaseSafe one (0.59 GB against 2.86 on windows-2025), which is the + # other half of why the ReleaseSafe-only restriction was mis-founded. + echo "cache_bytes=$(du -sk .zig-cache 2>/dev/null | cut -f1 | awk '{print $1*1024}')" echo "cache_hit=${{ steps.zig-cache.outputs.cache-hit || 'false' }}" echo "cache_matched_key=${{ steps.zig-cache.outputs.cache-matched-key || 'none' }}" echo "build_seconds=${BUILD_SECONDS:-NA}" @@ -292,20 +566,49 @@ jobs: if: always() uses: actions/upload-artifact@v6 with: - name: ci-timing-${{ matrix.os }}-${{ matrix.mode }} - path: ci-timing-${{ matrix.os }}-${{ matrix.mode }}.txt + name: ci-timing-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }} + path: ci-timing-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}.txt retention-days: 30 # CI cache refresh chore — full post-test cache save. `if: always()` # so a red test leg still saves whatever it compiled. This entry is # newer than the post-build save of the same run, so the zon-level # prefix fallback serves it first to the next run. + # M1.1.14 — THE ALL-OR-NOTHING GUARD WAS HALF-APPLIED, and this is where. + # `Save Zig cache (final)` carried `always() && matrix.mode == 'ReleaseSafe'` + # and never consulted `steps.cache-size.outputs.save`, so every run that + # printed `SKIPPING the save` saved anyway one step later — measured on run + # 31932309771, where the pre-check warned at 3 094 724 608 bytes, the + # post-build save was correctly skipped, and this one reported + # `Cache saved with key: …`. One of two save steps honoured the guard. + # + # It gets its OWN measurement rather than reusing the earlier output, because + # `always()` is deliberate here — the cache is worth saving even when the + # tests failed — and the earlier step is skipped when the build fails, which + # would silently turn `always()` into `never` on exactly those runs. + - name: Measure the Zig cache before the final save + if: always() + id: cache-size-final + shell: bash + run: | + set -euo pipefail + limit=10737418240 + bytes=$(du -sk .zig-cache 2>/dev/null | cut -f1 || echo 0) + bytes=$((bytes * 1024)) + echo "zig cache (final): $bytes bytes (cap $limit)" + if [ "$bytes" -gt "$limit" ]; then + echo "::warning::Zig cache is $bytes bytes, over the $limit cap — SKIPPING the final save." + echo "save=false" >> "$GITHUB_OUTPUT" + else + echo "save=true" >> "$GITHUB_OUTPUT" + fi + - name: Save Zig cache (final) - if: always() && matrix.mode == 'ReleaseSafe' + if: always() && steps.cache-size-final.outputs.save == 'true' uses: actions/cache/save@v5 with: path: .zig-cache - key: zig-${{ matrix.os }}-${{ matrix.mode }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} + key: zig-${{ matrix.os }}-${{ matrix.mode }}-f64_${{ matrix.precision }}-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }}-${{ github.sha }} runtime-smoke-test: # M0.4 § Scope Post-Review — first application of the runtime @@ -334,9 +637,9 @@ jobs: uses: actions/cache@v5 with: path: .zig-cache - key: zig-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} + key: zig-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} restore-keys: | - zig-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_VERSION }}- + zig-ubuntu-24.04-ReleaseSafe-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}- - name: Install weston + Mesa Vulkan software drivers run: | @@ -369,7 +672,7 @@ jobs: - name: zig build (ReleaseSafe) run: | set -euo pipefail - zig build -Doptimize=ReleaseSafe + zig build -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} - name: Launch weston headless backend run: | @@ -392,7 +695,7 @@ jobs: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json run: | set -euo pipefail - zig build run-example-triangle -- \ + zig build run-example-triangle -Dcpu=${{ env.ZIG_CPU }} -- \ --smoke-test --vulkan-driver=software --capture-frame=10 - name: Verify PSNR vs golden @@ -413,7 +716,7 @@ jobs: # `test-render-capture` invocation (~3-5 min/run). The test raises a # typed error when PSNR < 40 dB, so a non-zero exit propagates # through pipefail. - zig build test-ppm-psnr -Doptimize=ReleaseSafe --summary all 2>&1 | tee test-output.txt + zig build test-ppm-psnr -Doptimize=ReleaseSafe -Dcpu=${{ env.ZIG_CPU }} --summary all 2>&1 | tee test-output.txt - name: Upload capture artifact if: always() @@ -449,9 +752,9 @@ jobs: uses: actions/cache@v5 with: path: .zig-cache - key: zig-ubuntu-24.04-Debug-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} + key: zig-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}-${{ hashFiles('build.zig.zon') }} restore-keys: | - zig-ubuntu-24.04-Debug-${{ env.ZIG_VERSION }}- + zig-ubuntu-24.04-Debug-${{ env.ZIG_CPU }}-${{ env.ZIG_VERSION }}- - name: Install Mesa Vulkan (lavapipe) + validation layers + weston run: | @@ -479,7 +782,7 @@ jobs: mkdir -p out # Default `zig build` = Debug → render.zig enable_validation = true → # the validation layers load. Offscreen render → no surface/weston. - zig build run-vertical-slice -- --smoke-test --capture out/vertical_slice.ppm 2>&1 | tee slice-smoke.log + zig build run-vertical-slice -Dcpu=${{ env.ZIG_CPU }} -- --smoke-test --capture out/vertical_slice.ppm 2>&1 | tee slice-smoke.log echo "--- a frame was composed + captured ---" test -s out/vertical_slice.ppm echo "--- validation-clean (no Vulkan VUID / Validation Error) ---" @@ -498,7 +801,7 @@ jobs: # E5 / C0.8 end-to-end: an editor-stub thread sends a real # ModifyComponent over the M0.7 transport; the slice applies it to the # live World; the render reflects it. Offscreen capture, validation on. - zig build run-vertical-slice -- --ipc-edit --capture out/vertical_slice_ipc.ppm 2>&1 | tee slice-c08.log + zig build run-vertical-slice -Dcpu=${{ env.ZIG_CPU }} -- --ipc-edit --capture out/vertical_slice_ipc.ppm 2>&1 | tee slice-c08.log echo "--- the post-edit frame composed + captured ---" test -s out/vertical_slice_ipc.ppm if grep -E "VUID-|Validation Error" slice-c08.log; then @@ -522,7 +825,7 @@ jobs: # The editor blits the runtime mire via the E6-consolidated # src/editor/vk_blit.zig (now on the GAL); Debug build → GAL # validation layer + syncval feature. - zig build run-ipc-demo -- --frames=120 > vkblit.log 2>&1 || true + zig build run-ipc-demo -Dcpu=${{ env.ZIG_CPU }} -- --frames=120 > vkblit.log 2>&1 || true echo "=== vk_blit GAL blit on lavapipe (E6) ===" echo "--- distinct VUIDs ---" grep -oE "VUID-[A-Za-z0-9-]+" vkblit.log | sort -u || echo " (none)" @@ -558,6 +861,229 @@ jobs: vkblit.log retention-days: 30 + witness-generation: + # M1.1.14 — generation of the committed determinism witnesses, GATED ON A + # COMMIT TRAILER. + # + # WHY A JOB HERE AND NOT A `workflow_dispatch` WORKFLOW OF ITS OWN. Measured, + # not assumed: `gh workflow run .yml --ref ` returns + # `HTTP 404: workflow not found on the default branch`. GitHub requires a + # `workflow_dispatch` workflow to exist on the DEFAULT branch before it can be + # dispatched at all, whatever `--ref` says — so a new workflow file cannot be + # triggered until after the milestone merges, which is too late to produce the + # witnesses the milestone must commit. `ci.yml` is already on the default + # branch, so a job added here runs on the PR head today. + # + # WHY A COMMIT TRAILER RATHER THAN A DISPATCH INPUT. A witness pins a result in + # time, so producing one must be a DECLARED ACT and never a side effect. The + # declaration is `Witness-regen: ` in the head commit message, which is + # STRONGER than a form field: it is reviewed in the diff, greppable, and + # permanent, where a dispatch input leaves no trace in the repository at all. + # Absent the trailer the job skips and costs nothing. + # + # PERMANENT, not scaffolding. M1.1.25 replays the harness at N workers, M1.A on + # a rebuilt scheduler DAG, and every shape added later moves the scenario — + # each needs a regeneration with provenance. + # + # NOT IN `ci-gate`'s `needs`. A conditional job in a required dependency makes + # the gate skip or fail depending on configuration; this job is a producer, not + # a verifier. What VERIFIES the witnesses is the ordinary matrix, comparing + # against the committed tree. + # + # LINUX BY CONSTRUCTION, and the consequence is intended: Linux is the + # reference, Windows the verifier. A Windows divergence is then THE measurement + # of the milestone. Regenerating on Windows to make such a divergence pass is + # forbidden by name — that is the silently re-baselined witness. + needs: changes + if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + # CHECK OUT THE PR HEAD COMMIT ITSELF, not the synthetic merge commit. + # MEASURED, and it took a live run to see it: on a `pull_request` event + # `actions/checkout` defaults to `refs/pull/N/merge`, whose message is + # `Merge into ` — so `git log -1 --pretty=%B` read GitHub's + # generated text and never the commit the author wrote. The trailer gate + # could not fire, and it failed the way that costs the most: SILENTLY, by + # skipping, on a green job. + # + # The second consequence is worse and would have SHIPPED: `github.sha` is + # that same merge commit, an ephemeral object outside the branch history + # that nobody can resolve later — so `PROVENANCE.txt`, whose entire purpose + # is provenance, would have named a sha no reader could check out. Pinning + # the head sha fixes both, and it is the right tree to generate from + # anyway: a witness pins the reviewed commit, not a merge preview of it. + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Read the Witness-regen trailer + id: trailer + shell: bash + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # LOUD, not silent, if the checkout ever drifts back. The failure above + # was invisible precisely because "no trailer" and "wrong commit" are + # the same observable, so the two are separated here: a checkout that + # is not the head commit FAILS, and only then may an absent trailer + # legitimately skip. + actual="$(git rev-parse HEAD)" + if [ "$actual" != "$HEAD_SHA" ]; then + echo "::error::checked out $actual, expected PR head $HEAD_SHA" + echo "::error::the trailer would be read from the wrong commit message" + exit 1 + fi + body="$(git log -1 --pretty=%B)" + echo "--- head commit $actual ---"; echo "$body" + if reason="$(printf '%s\n' "$body" | sed -n 's/^Witness-regen:[[:space:]]*//p' | head -1)" && [ -n "$reason" ]; then + echo "requested=true" >> "$GITHUB_OUTPUT" + echo "reason=$reason" >> "$GITHUB_OUTPUT" + echo "--- witness generation REQUESTED: $reason ---" + else + echo "requested=false" >> "$GITHUB_OUTPUT" + echo "--- no Witness-regen trailer; skipping generation ---" + fi + + - uses: weldengine/setup-zig@v0.1.0 + if: steps.trailer.outputs.requested == 'true' + with: + version: ${{ env.ZIG_VERSION }} + + - name: Generate one witness set per (precision, mode) + if: steps.trailer.outputs.requested == 'true' + shell: bash + run: | + set -euo pipefail + for prec in false true; do + for mode in Debug ReleaseSafe; do + tag=$([ "$prec" = "true" ] && echo f64 || echo f32) + dir="out/$tag-$mode" + mkdir -p "$dir" + echo "=== $tag / $mode ===" + zig build forge-determinism \ + -Doptimize=$mode -Dphysics_f64=$prec -Dcpu=${{ env.ZIG_CPU }} \ + -- --write-witness "$dir" + done + done + + # The two mode-independent witness kinds are keyed by PRECISION ALONE, which + # asserts that Debug and ReleaseSafe agree on them. That is a hypothesis, so + # it is MEASURED here rather than assumed by the file name. + # + # It is MEASURED AND REPORTED, and it does NOT fail the job. C1.1 level 1 + # requires bit-exactness "à ISA, BUILD, configuration et nombre de workers + # identiques" — Debug and ReleaseSafe are two builds, so a disagreement + # between them does not breach level 1 and must not be dressed up as a + # level-1 failure. What a disagreement DOES breach is the two frozen keys, + # which is a design question and a STOP, not something this job may decide. + # + # This cell is also where such a disagreement is most likely, and that is + # measured rather than feared: `x86_64-linux` is the ONLY one of the eight + # (target, mode) corners the engine builds whose Debug uses Zig's SELF-HOSTED + # backend while its ReleaseSafe uses LLVM. Windows x86_64 and both aarch64 + # targets are LLVM in both modes. So this comparison is the only one in the + # matrix that puts two independent instruction selections against each other. + - name: Measure the cross-mode agreement and assemble the set + if: steps.trailer.outputs.requested == 'true' + shell: bash + run: | + set -euo pipefail + mkdir -p witnesses + agree=true + for tag in f32 f64; do + for kind in discrete reference-window; do + a="out/$tag-Debug/$kind-$tag.bin" + b="out/$tag-ReleaseSafe/$kind-$tag.bin" + if cmp -s "$a" "$b"; then + echo "$kind-$tag.bin: Debug == ReleaseSafe" + else + agree=false + echo "::warning::$kind-$tag.bin DIFFERS between Debug and ReleaseSafe." + echo "::warning::Its witness key carries precision only, so the two keys are wrong." + echo "::warning::This is a STOP and a design question, not a level-1 failure:" + echo "::warning::C1.1 level 1 holds at identical BUILD, and these are two builds." + fi + # The REFERENCE WINDOW is taken from ReleaseSafe, NAMED and not + # implicit: without naming it a later regeneration could switch mode + # in silence, and if the two modes ever disagree the ARM64 cell would + # then fold an ISA difference and a BACKEND difference into one number. + cp "out/$tag-ReleaseSafe/$kind-$tag.bin" "witnesses/$kind-$tag.bin" + done + for mode in Debug ReleaseSafe; do + cp "out/$tag-$mode/continuous-chain-$tag-$mode.bin" "witnesses/" + done + done + echo "cross_mode_agreement=$agree" + echo "--- witness set (reference window and discrete taken from ReleaseSafe) ---" + ls -l witnesses/ + sha256sum witnesses/* | tee witnesses/SHA256SUMS.txt + # THE OUTPUT OF `zig version`, not the env var that requested it. The + # env var is what the workflow ASKED the setup action for; this is what + # the toolchain on the runner REPORTS. Recording the request in place of + # the observation is the class this milestone keeps finding, and a + # witness is exactly the artefact where it would cost most: the pinned + # version is one of `ARCH-031`'s three axes, and a silent drift between + # requested and installed would invalidate every file here with nothing + # in the record to show it. + zig_reported="$(zig version)" + echo "zig version reports: $zig_reported" + { + echo "reason=${{ steps.trailer.outputs.reason }}" + echo "cell=ubuntu-24.04" + echo "cpu=${{ env.ZIG_CPU }}" + echo "zig_requested=${{ env.ZIG_VERSION }}" + echo "zig_version_output=$zig_reported" + echo "reference_window_mode=ReleaseSafe" + echo "discrete_mode=ReleaseSafe" + echo "cross_mode_agreement=$agree" + # The PR HEAD sha, never `github.sha` — on a `pull_request` event the + # latter is the ephemeral merge commit, which is outside the branch + # history and unresolvable by any later reader. + echo "sha=${{ github.event.pull_request.head.sha }}" + echo "run=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + # PER FILE, and not only per set. A reader holding one witness must be + # able to answer "which build produced this?" without reconstructing + # the assembly rules from the job. The two mode-independent kinds carry + # their generator mode nowhere else — their key is precision alone — + # so for them this table is the only record there is. + echo "---" + for f in witnesses/*.bin; do + base="$(basename "$f")" + case "$base" in + continuous-chain-*-Debug.bin) gen=Debug ;; + continuous-chain-*-ReleaseSafe.bin) gen=ReleaseSafe ;; + *) gen=ReleaseSafe ;; + esac + echo "witness=$base mode=$gen zig=$zig_reported cpu=${{ env.ZIG_CPU }} run=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + done + } > witnesses/PROVENANCE.txt + cat witnesses/PROVENANCE.txt + + - name: Upload the witness set + if: steps.trailer.outputs.requested == 'true' + uses: actions/upload-artifact@v6 + with: + name: determinism-witnesses + path: witnesses/ + retention-days: 90 + + # THE PER-MODE OUTPUTS, kept whole. The assembled set above collapses the + # two mode-independent kinds onto their ReleaseSafe copy, so when the + # cross-mode measurement reports a DISAGREEMENT the artifact no longer + # contains the disagreeing pair — the one thing needed to characterise it. + # Measured the hard way: the first run reported reference-window-f32 + # differing and left nothing to diff. A measurement that reports a + # difference must retain both sides of it. + - name: Upload the raw per-mode outputs + if: steps.trailer.outputs.requested == 'true' + uses: actions/upload-artifact@v6 + with: + name: determinism-raw-per-mode + path: out/ + retention-days: 90 + # M1.0.3-followup — l'UNIQUE check required. La branch protection ne requiert # que ce job (Guy applique les réglages repo). Il agrège les jobs lourds : # vert si tous success OU skipped (PR doc-only), rouge si l'un est failure / diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml index 089076b2..9649cf79 100644 --- a/.github/workflows/nightly-fuzz.yml +++ b/.github/workflows/nightly-fuzz.yml @@ -4,8 +4,16 @@ name: Nightly IPC fuzz # promoted to nightly CI at M0.7 / E4. Runs on Linux + Windows and # archives the stdout digest as an artifact (G3 gate). Scheduled runs # only fire from the default branch (GitHub rule), so this activates once -# the M0.7 branch is squash-merged to `main`; `workflow_dispatch` lets it -# be triggered manually from the Actions tab in the meantime. +# the M0.7 branch is squash-merged to `main`. +# +# M1.1.14 correction, MEASURED: the sentence that stood here — that +# `workflow_dispatch` lets it be triggered manually from the Actions tab in the +# meantime — is FALSE, and had been dormant since M0.7 because nobody tried it +# from a branch. `gh workflow run --ref ` returns +# `HTTP 404: workflow not found on the default branch`: a `workflow_dispatch` +# workflow must ALSO exist on the default branch before it can be dispatched at +# all, whatever `--ref` says. Dispatch is available for this file today only +# because it is already on `main`. on: schedule: # 04:00 UTC daily — off-peak for the shared runner pool. diff --git a/CLAUDE.md b/CLAUDE.md index 7eccf5a4..68808d6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,13 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | M1.1.13.1 — Rigid solver port SI + NGS → TGS Soft — code-complete, PR open. M1.1.13 is CLOSED, squash-merged to `main` (commit `4a35343`, tag `v0.11.13-sensors-triggers`). | -| Last released tag | `v0.11.13-sensors-triggers` (posted by Guy after merge) | -| Active branch | `phase-1/forge/solver-tgs-soft` (PR open, not merged) | -| Next planned milestone | M1.1.14 — cross-platform determinism (level 1 bit-exact intra-ISA, level 2 discrete parity inter-ISA on a CI `ubuntu-24.04-arm` cell). The corpus operation that preceded M1.1.13 is DONE: `engine-physics-forge.md` was split four ways into `engine-physics-solver.md` (§1.7, §1.8, §1.13), `engine-physics-queries.md` (§1.11 bar the per-shape models, §1.12) and `engine-physics-shapes.md` (§1.11.15, §1.11.17), section numbers deliberately NOT renumbered so the corpus's `§N` references stay valid. M1.1.0–M1.1.12 CLOSED. | +| Current milestone | M1.1.14 — Cross-platform determinism of `forge_3d` — PR #71 open (draft), NOT closed. An external review found **six defects, five of them the milestone's own dominant family**: an artefact rendering a verdict on something other than what it claims to measure, and answering green. All six are corrected (P1-4 regeneration, P1-3 the ten float-env sites, P1-1 the scenario's absent relief and unobserved character, P1-2 the cosine bit table, P1-5 the fourth trace's non-vacuity, P2-6 an unmeasurable window reading as a match) and the witnesses are RE-BASELINED. M1.1.13.1 is CLOSED, squash-merged to `main` (tag `v0.11.13-solver-tgs-soft`). | +| Last released tag | `v0.11.13-solver-tgs-soft` (posted by Guy after merge) | +| Active branch | `phase-1/forge/determinism` (PR #71 open, draft, not merged) | +| Next planned milestone | M1.1.15 — `step()` / `PhysicsWorld` / `PhysicsModule` freeze, ECS `Transform` sync, the `f32` → `Real` widening of the public surface as ONE grouped decision, and the typed-bus → Etch `EventStore` bridge that M1.1.13's sensor deltas wait on. M1.1.0–M1.1.13.1 CLOSED; M1.1.14 code-complete. **Determinism is now an INSTRUMENT the plan depends on**: M1.1.25 replays `zig build forge-determinism` at N workers and M1.A replays it on a rebuilt scheduler DAG, both at either precision. | +| CI matrix | `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` — **12 cells**, every one pinned `-Dcpu=baseline` (`ARCH-031` rule 6, third axis). `zig build lint` and `zig build forge-determinism` both run on the cell path; before M1.1.14 the first ran in NO workflow and the second in none either. Cache restored to every cell, keyed by os · mode · precision · cpu · zig version · zon hash · sha, with an all-or-nothing size guard on BOTH save steps. | +| Determinism instrument | `zig build forge-determinism` — canonical scenario, 1000 frames, one worker, no RNG, **NINE elements** since the review: the eighth and ninth are a kinematic character on a riser and three mesh ramps forming a closed bowl, plus a lone box that sleeps inside the compared window, whose surface cosines bracket `cos(max_slope)` on both sides so a wrong cosine costs METRES of trajectory. **Eight witnesses committed** under `src/modules/forge/forge_3d/tests/determinism/witnesses/` with `SHA256SUMS.txt` and a `PROVENANCE.txt` carrying run URL, cell, CPU pinning, PR-head sha, cross-mode result, the REPORTED `zig version`, and a per-file generator mode. Regeneration is gated on a `Witness-regen:` trailer in the PR head commit. **Replayed by M1.1.25 at N workers and by M1.A on a rebuilt DAG** — it is an instrument, not a test. | +| Test floor | **Per platform, never absolute.** `forge_3d` reconciles exactly everywhere — 557 collected against 557 source blocks — and it is THE oracle. The repository total is not: measured at M1.1.14 after the review, `ubuntu-24.04` and macOS collect 1869, `windows-2025` 1867, the difference being `shm_posix.zig` + `transport_posix.zig`. The `dead-tests` guard is ACTIVE on `zig build lint` and on the `pre-commit` hook, and its bilateral control — closure minus a DECLARED uncollected list against the suite's own collected total — reconciles on all three platforms. | ## Tags @@ -72,6 +75,7 @@ knowledge base — see § Quick links spec. | `v0.11.12-character-controller` | 2026-08-09 | M1.1.12 — Forge 3D: the kinematic character controller | Fourteenth M1.1 sub-milestone and the FIRST that is neither a shape nor a solver pass. Normative spec authored for it: `engine-physics-forge.md` §1.12, appended so nothing renumbers, §9 rewritten as the calling surface, and §1.12.6 rewritten four times under measurement. `engine-tier-interfaces.md` 0.6 → 0.8. `engine-movement.md` loses its ground raycast and its crouch mutation; `engine-gameplay-systems.md` §18 loses a triple duplicate declaration of a domain it does not own. THE CONTROLLER IS VIRTUAL AND CARRIES NO SIMULATED BODY, but it carries a broadphase PRESENCE — mandatory on `PhysicsModule`, optional per character, defaulting to ON. The argument is internal to the frozen surface: `CharacterDescriptor` has carried `collision_layer` since its original version, and in Weld the object layer is HOW an object declares itself visible to other callers, so either the character has a presence or that field has no observable effect. An earlier justification derived from a validation criterion was RETRACTED: criteria MEASURE whether the engine arrived somewhere and are not design inputs. Six entries added to the frozen surface in the last window there was, plus an error channel on `moveCharacter`, plus `BodyDescriptor.can_sleep`, plus `setBodyTransform` declared a TELEPORTATION. `PackedId.dead` reserves the all-ones no-handle pattern: `ground_body` defaulted to `0`, a LIVE handle to slot 0, so no bit pattern of that field meant absence. Position is the BASE of the capsule and never the centre, the offset living in exactly one named place. Ground determination is a BOUNDED DOWNWARD SWEEP and not manifolds at the current pose, a resting character standing `padding` ABOVE its floor. THE SLIDE IS CONSTRAINED BY SLOPE, capped at `max(up_before, 0)` and not at `up_before`, which would drive INTO the plane on an inclined face. DEPENETRATION PUSHES OUT AND NEVER THROUGH, on the BASE and not the centre. FOUR TUNNELLING DEFECTS WERE CLOSED AND EACH WAS FOUND BY EXTERNAL REVIEW: a mesh wall traversable because a per-CONTACT verdict excluded a whole BODY; a noise band on `n · d` that closed all twenty-eight squeeze cells and opened a window on the DEFAULT path, penetration being `distance × \|n·d\|` and unbounded in distance — no band on that quantity can work, the transport residue tracking `floatEps(Real)` while a grazing incidence is GEOMETRIC; a face normal used where a triangle is FINITE and reachable by its edge; and a saturated bound that left the tail of a displacement unswept. `castShapeBody` had a contract PER SHAPE CLASS and per call path — direction domain, normal frame, behaviour at `d = 0` — measured as an eighteen-cell table, uniformised by conditioning once before dispatch, and now PINNED by a parameterised test so the next divergence breaks that test and not a character scene three milestones later. The DISPLACEMENT gained a domain, the first CALL PARAMETER of the module to have one: the domain table had tabulated descriptor FIELDS and never call parameters. It is the `f32` public range, evaluated in a fixed wider arithmetic whose EXPRESSION is normative — widening is exact, squares summed in `x`, `y`, `z` order, `sqrt`, `<=` — because a domain declared on what is COMPUTED is only defined if the computation is. Both sides of the boundary are pinned, and three cases lock width, order and reduction form independently. Green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe: 419 → 495 tests. FIVE METHODS THIS MILESTONE LEAVES BEHIND, each having cost a round: two sources answering differently about the same geometric fact are a DEFECT and never an envelope; one test, one verdict, because a mutation probe on a multi-case body proves that AT LEAST ONE case discriminates and never that each does; an oracle judging a ROUNDING must be of an arithmetic that rounding does not reach; an ABSENCE witness is worth a presence witness, a formulation corrected upstream leaving its earlier version downstream; and on a floating boundary an intuition is not a weak hypothesis but a FALSE one until measured — every estimate made on this milestone without calculating was refuted, on both sides of the review. A LAST RED, on `ubuntu-24.04 / Debug` alone, refuted a pinned VALUE on a residual cell and exposed something larger: the CI matrix carries NO `-Dphysics_f64=true` corner, so every f64 claim of this milestone rests on a SINGLE machine. Both instances of a measurement pinned as a property were found by a different ARCHITECTURE on the only leg CI exercises, while the one assertion pinning an f64 state was never contradicted because nothing could contradict it. Recorded as a sixth open decision, not charged to this milestone. Out (later, NOT debt): the Etch surface of the controller (M1.1.15); the ECS `VirtualCharacter` component (M1.1.15); `moveKinematic`'s body (M1.1.15); the residual yaw dependence of an insoluble squeeze, cause partly named and bounded by measurement, varying by architecture and by build mode as well as by precision; `CharacterMoveResult2D.collisions` and the 2D character symmetry (M1.8.x). | | `v0.11.13-sensors-triggers` | 2026-08-11 | M1.1.13 — Forge 3D: sensors and triggers | Fifteenth M1.1 sub-milestone, and the one that closes a chain open since the corpus's first physics draft: `TriggerEnter` / `TriggerExit` were declared and `CollisionShape.is_trigger` specified, while `BroadphaseLayer.trigger` had NO producer and NO consumer — exactly where `BodyType.kinematic` stood before M1.1.12. Delivers the LOWER HALF by design: the engine produces an overlap STATE and two DELTAS, and the translation into typed events on the Tier 0 bus is M1.1.15's, because `forge_3d` depends only on `foundation/math` and `forge/api/` (a C1.1 exit metric) while the bus lives on `World`. **The state and not the event stream is the source of truth**: the Tier 0 bus drops its oldest entry on saturation, so an ownership set rebuilt from the flow would be wrong on the first saturation. Normative model: `engine-physics-solver.md` §1.13, twelve subsections, with §1.13.6 REVISED mid-milestone to lift this milestone's single design blocker. `engine-tier-interfaces.md` 0.8 → 0.9, §12 count unchanged at 27 — **no interface function was added, and that is the design**. TWO FIELDS ON `BodyDescriptor` IN THE SECOND-TO-LAST WINDOW: `is_trigger` and `trigger_layer_mask`, transcribed field for field, name for name, default for default; after the M1.1.15 freeze neither could land at all and nothing would let a caller declare a trigger. THE ROLE IS A PROPERTY OF THE INSTANCE, never of the geometry — the shape store is shared, so the field on `ShapeDescriptor` would force two bodies sharing a sphere to share their nature; pinned as an ABSENCE over the union's five payload variants, with the visit count asserted so a removed variant cannot shrink the check in silence. `CollisionShape` gains the authoring mask, 48 → 52 bytes, and THE THREE OFFSETS THAT DECIDE THE 52 ARE PINNED — `collision_layer` 44, `is_trigger` 45, the `u32` 48 — in the comptime block AND in the test that doubles it, because size alone cannot catch a different arrangement landing on the same size; measured by PERMUTING two adjacent one-byte fields, where the size and align pins pass and the offset pins fall. A first version of that comment stated two false facts about the padding and was rewritten to say no more than the pins establish. BROAD CLASS: `broadLayerFor(is_trigger, body_type)`, DERIVED and never stored, role first then `static` to `static` and EVERYTHING ELSE to `dynamic` — a kinematic body lands in `dynamic` deliberately, the class naming what MOVES and not what is SIMULATED. **THE `trigger` ROW AND COLUMN OF `default_layer_pairs` GO TO `false` IN FULL, REVISING AN M1.1.1 DECISION** that set `dynamic × trigger` to `true` and asserted it positively: killing the pair at the source is less work than filtering it downstream every tick and a stronger guarantee, since a trigger that never reaches step 4 cannot be forgotten by a downstream filter. The M1.1.1 assertion is DELETED, not commented, and replaced by the absence on the same scene plus a direct read of the constant in both index orders and a full symmetry check. It follows that THE SENSOR PASS CANNOT CONSULT THAT MATRIX — it reads `false` everywhere — and detection is filtered by the trigger's own UNILATERAL object-layer mask instead: the matrix governs the RESPONSE absolutely, the mask governs what is SEEN, and the two never substitute (§1.13.2). THE PASS DOES NOT REUSE `computePairs`, AND THAT IS AN IMPOSSIBILITY RATHER THAN A PREFERENCE: pair generation is moved-driven, so it returns a DELTA and never a snapshot, and two motionless bodies overlapping for a hundred ticks do not appear in it. `pipeline/sensor.zig` enumerates the TRIGGER proxies and descends per trigger; `Broadphase` gains `forEachInLayer` and `queryHalfSpace` and `Bvh` gains `forEachLeaf`, there having been no way to enumerate one layer's proxies at all — the second having first been `queryHalfSpaceTrees`, named for its OMISSION because the domain bound excluded the unbounded lists, and the bound falling it is the OMISSION that went and not merely the name: it visits trees AND lists, symmetric with `queryAabb`. **§1.13.6's TWO NEW RULES**, which lifted the blocker: the PROBE IS FIXED BY A RULE and never by availability — trigger when convex, candidate otherwise, ALWAYS the trigger when both are, because the boolean is symmetric in exact arithmetic but nothing guarantees bit equality of the two orders in float and M1.1.14 must VERIFY that order rather than establish it; and THE SENSOR ROLE IS REFUSED ON A MESH by typed error at creation (`error.TriggerShapeMustBeSolid`), which is GEOMETRY and not an implementation limit: a `MeshShape` is a SURFACE and not a solid (§1.11.17), so membership is false everywhere on it — a sensor answers « who is inside » and a surface has no inside. THE HALF-SPACE KEEPS THE ROLE, being a volume with a well-defined interior: it is the kill plane under the level. A DOMAIN BOUND excluding {half-space, mesh} × {half-space, mesh} stood through two closing reviews and was RETRACTED at the second: it grouped the two by BODY TYPE where the question is whether the shape has an INTERIOR, and it was justified by a second false claim — that two statics' overlap cannot vary, when a static body is movable by pose write and this module treats that case explicitly. With mesh triggers refused at the source, the only cell that was a real piece of work — mesh × mesh — is unreachable, and the two remaining kernels are written and analytic: two half-spaces meet unless their normals are exactly opposite with disjoint boundaries, and a surface meets a half-space iff one of the vertices REFERENCED BY ITS TRIANGLES does, a triangle being the convex hull of its three — the stored array is NOT that set, `MeshData` validating the index bound and never the converse, so an unreferenced vertex inside the solid made a surface wholly outside it answer overlap. NO FALSE NEGATIVE REMAINS. That bound was DECORATIVE when first written, an `orelse return` below it returning the same answer, and now unwraps the candidate probe so its removal fires. THE OBSERVABLE STATE IS A SET OF ORIENTED ENTITY PAIRS, never body handles: AGGREGATION IS THE DEDUP (several body overlaps between two entities collapse to one pair, so the exit fires when the LAST disappears, with no special case), reflexive pairs are dropped, the pair is oriented from the trigger so two mutually detecting triggers produce TWO pairs, and the set is REBUILT IN FULL every tick — which is what makes body-handle recycling harmless, a property of the reconstruction and not of the type. Two deltas and no third list: §1.13.12 refuses `TriggerStay` where both corpora carry a `CollisionStay`. Sorted by `(trigger_entity, other_entity)` on the COMPLETE identity, index AND generation, the comparator WRITTEN OUT rather than bitcast to the packed `u64` whose field order is a layout accident; no hashed container anywhere. **THE PASS IS FILTERED BY NO SLEEP STATE ON EITHER SIDE, and that is what forbids the phantom exit STRUCTURALLY**: membership derived from anything the sleep system filters would make a body that falls asleep inside a trigger leave the set without moving by one ULP. It runs at STEP 10 BIS — after the proxy update so poses are final and every proxy is valid including sleepers', before step 11 so membership is established for the tick in which a body falls asleep. The price is explicit and accepted: a fully resting scene pays the pass every tick. THREE EXCLUSIONS FROM THE RESPONSE: no constraint, no impulse, no island; no wake cause; and the character controller ignores trigger bodies BY CONSTRUCTION in its collection paths — `admitsCandidate`, one function for all three collectors, refusing stale then ROLE then mask, because a rule posted in two places out of three is how the third comes to disagree. Left alone a trigger would stop blocking rigid bodies while still blocking the player, an invisible wall for the player alone. A FOURTH observable was found in review: `WorstOverlap` serves the depenetration AND `resizeCharacter`'s occupancy test, whose verdict is a `bool` no position case exercises — and `engine-movement.md` gates the stand-up on it, so a trigger ceiling answering `false` is a crouched character who can never stand again. THE EIGHT QUERY ENTRIES, BY CONTRAST, KEEP SEEING TRIGGERS, tested in BOTH directions so nobody restores the symmetry by reflex. Bench REPORTED, not gated, ReleaseFast over 1000 static bodies: floor 5.0 ns with nothing to enumerate, 1005.0 ns for one trigger holding 8 pairs, 122745.0 ns for 64 triggers holding 1002 — the cost follows the TRIGGER count and its pair yield, which is what the triggers-outward direction intends. 495 → 526 forge tests green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe. METHOD, and it is the milestone's real yield: every counter-factual changes the OBJECT and never the expected constant — an oracle judging a LAYOUT is tested by a change of layout, one judging a SET by a change of the set — and three tooling facts were self-reported, the third RETROACTIVE: a compile error and an assertion failure share an exit code, so the `compilation errors` grep was replayed over all 27 retained counter-factual logs of gates A to E, finding no unknown invalidated probe and refining the rule — for a COMPTIME assert the compile error IS the measurement. Out (later, NOT debt): all emission and the `TriggerEnter` / `TriggerExit` translation, plus the bus-to-Etch bridge (M1.1.15); any treatment of the `debris` class and any `is_debris` field; an object-layer MATRIX; `step()` / `PhysicsWorld` / `PhysicsModule` instantiation and the production `BodyType → BroadphaseLayer` wiring (M1.1.15); renaming the `dynamic` broad class; any hysteresis, debounce or dwell time; `TriggerStay` in any form; `forge_2d` and `Collider2D.is_sensor` (M1.8.11). | | `v0.11.13-solver-tgs-soft` | 2026-08-13 | M1.1.13.1 — Rigid solver port: SI + NGS → TGS Soft | Substep loop (4 substeps, soft constraints, relax-only friction, restitution `<=` + `total_normal_impulse`); NGS removed. Deviation B1: `integration.zig` decomposed, reset AFTER the last substep. Two N8 re-baselines in `mesh_test`. Rest overlap 5.069 / 7.217 mm replaces `5.9e-7`. 527 tests. Detail: `briefs/m1.1.13.1-solver-tgs-soft.md`. | +| `v0.11.14-determinism` | 2026-08-16 | M1.1.14 — Cross-platform determinism of `forge_3d` | C1.1 **level 1** green — 1000-frame hash chain bit-identical between `ubuntu-24.04` and `windows-2025` on all four `(precision, mode)` keys — and **level 2 point 1** green on a real `ubuntu-24.04-arm` cell: the four discrete traces identical to the x86_64 witness over 60 frames, both precisions, both modes. FOUR behavioural changes, not the two the frozen Scope named: deterministic `cos` replacing `@cos` at the `max_slope` conversion; the float environment INSTALLED at thread spawn and at each process entry and ASSERTED at the physics entry; float `@reduce` replaced by an explicit left fold at 18 sites, behind a `no_float_reduce` lint rule — a Zig backend defect, written up and NOT filed; and `shm.zig` moved from `std.heap.pageSize()` to `page_size_min` at 8 sites, which is what made AArch64 compile at all. Eight witnesses committed with per-file provenance and a reader that names the first differing frame AND which of the four invariants moved. CI matrix `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` = **12 cells**, every one pinned to `-Dcpu=baseline`, with `zig build lint` and `zig build forge-determinism` on the cell path. Dead-test guard ACTIVE. The M1.1.13.1 slider residual PERSISTS and is characterised: 4 ULP at f32, 3 at f64, against `2^31` an energy injection would need — rounding, not energy. 527 → 552 forge tests. **AN EXTERNAL REVIEW THEN FOUND SIX DEFECTS, five of them the milestone's own dominant family** — an artefact judging something other than what it claims to measure, and answering green — and the class therefore SURVIVES ITS OWN DOCTRINE wherever no mechanical guard covers it, which is the milestone's real finding. All six corrected. **Regeneration worked only when it was pointless**: with correct witnesses `--write-witness` exited 0, with one byte altered — the only case where regenerating means anything — it wrote the files and then exited 1, so under `set -euo pipefail` the CI step died exactly in the case it exists for. **`ARCH-031` rule 5's site set was measured, not inherited: TEN, not three** — seven were uncovered including `determinism_main` itself, the instrument asserting the guarantee without installing it, and three sites are inside modules where `install()`'s own doc comment declared a fourth-in-a-module to be a defect; the enumeration is DELETED in favour of the predicate, and the class was swept over four texts. **BIT-NEUTRALITY OF THE INSTALL IS MEASURED, and by a deduction stronger than the byte comparison**: the environment assertion, live in Debug AND ReleaseSafe with its reader witnessed on both ISAs by per-field perturbations whose encoding differs between architectures, PASSED on all 12 cells when nothing installed on the witness path — so the inherited state equalled `engine_default` everywhere, and installing a value into a state already holding it is a bit-level no-op; corroborated end to end on the 8 cells where level 1 applies. **The scenario had NEITHER a step NOR a slope** while its header claimed both, and the character reached NO artifact — `mobile` holds rigid bodies and a virtual character owns none — so the controller ran 1000 frames and every bit was discarded; the guard written for that case could not catch it, a count pinned alongside the change it must catch catching nothing. Fixed with mesh ramps (rotated boxes abandoned after three measured failures, one wedging the character in a crevice for 775 frames), a bowl closing an unbounded 2.2 m/cycle drift that would empty the instrument at a longer replay, and the walk raised 0.03 → 0.06 because **the scenario walked below the threshold of its own step arm** — swept, no riser is climbed at 0.03 at any height. The cosine bracket bites BOTH ways, measured: `max_y` 0.0063 / 0.9463 / 2.6707 at cosines 0.9211 / 0.7074 / 0.3624. **The deterministic cosine is pinned to an ORACLE THAT NEVER CALLS `@cos`** — pi to 80 digits in exact decimal arithmetic, computed twice by different Machin-like formulas and required to agree to 70 — in a two-column table separating CORRECTNESS from REPRODUCIBILITY; at f32 the implementation is correctly rounded on all twelve arguments and at f64 nine of twelve, worst absolute error 3.14 eps. **And the bound had to be ABSOLUTE, not in ULP**: at the f64 nearest pi/2 the error is 1.6e11 ULP of the true value while being 2.0e-21 absolute, the smallest in the table, so a ULP bound would fail on the most accurate row. The fourth discrete trace is now an ORACLE rather than an accumulator — one real removal at frame 196, the mesh against the frictionless sphere, asserted ON THE SET and never on its cardinality because the size returns to 11 one tick later. `divergenceFrame` no longer answers `none` on an empty, truncated or wrong-precision window. Witnesses RE-BASELINED with a `Witness-regen:` trailer, and the prediction written before the run held exactly: 4 chain witnesses CHANGED, the 4 ISA-independent ones IDENTICAL, the 4 ARM cells green through the stale-witness push. Detail: `briefs/m1.1.14-determinism.md`. | ### Hotfixes (untagged) @@ -144,16 +148,28 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **Tier 0 IPC — bounded receive, unowned (opened at M1.1.9)**: `engine-zig-conventions.md` §13 line 897 requires an internal timeout ≤ 5 s with clean resource teardown for any test awaiting an external resource. `connection.recvFrame` has neither a non-blocking variant nor a deadline (`src/core/ipc/connection.zig:123` and `:157` are the only receive entries), and the IPC test targets are built by a loop that does not wire `test_watchdog` (only the `test_specs` loop does, `build.zig:618`), so a hang there never stalls the sibling IPC cases but never lets `zig build test` complete either. Closing §13 for real needs a bounded receive primitive in Tier 0 IPC. Owned by whoever next opens that surface; not a physics milestone. - **M1.1.15 owns three M1.1.8 leftovers**: the wake fixpoint's ROUND COUNT is unpinned (the E4 fix that removed a redundant round per resting tick changed no result, so no test could have caught it — build telemetry belongs with the orchestrator); `build`'s per-tick deferred-index buffer is the one allocation on the build path and moves to the orchestrator's scratch (`build` owns no state, so it cannot reuse it); and the production W4 wiring — removal of a body, teleport of a static/kinematic — wakes the sleepers retained in a pair with it, proven at harness level at M1.1.8, unwired until `PhysicsWorld` exists. - **Windows bench job budget (`bench.yml` `timeout-minutes: 10`)**: marginal and WILL recur. Closed by EXPERIMENT at M1.1.11.1, not by argument: the job was cancelled at 9m28 on `bench-ecs-smoke (windows-2025)`, passed on rerun at 7m34 on the SAME commit, and `build-and-test (windows-2025, ReleaseSafe)` — which compiles the whole forge suite including the `i1024`/`i8192` tiers — passed in 42m7, so the code is not implicated. The runner is intrinsically at the edge: `build-and-test (windows-2025, Debug)` takes 9m19 for comparable work against a 10-minute budget that includes checkout, Zig setup and cache restore. Two options, neither taken here: raise the budget, or drop the Windows bench from the PR matrix. Whoever hits the next cancellation should read this entry before suspecting their change. -- **NGS energy injection watch (since M1.1.11.1)**: a FRICTIONLESS, undamped slider on a flat mesh seam retains 5.000001 m/s of 5 over sixty ticks — a 2e-7 relative GAIN. Negligible at this scale and no action taken, but a contact solver that adds energy is a stability seed, and this is the signature. RE-READ AT M1.1.12 AND THE ANSWER IS ARITHMETIC, NOT NGS: the retained speed is `5.0000005` at f32 — `nextafter(5)` is `5.000000476837158`, so the gain is EXACTLY ONE ULP — and exactly `5` at f64, where the gain is zero. A solver adding energy would add it at both precisions. Unchanged digit for digit between the M1.1.12 branch and `main`. RE-MEASURED AT M1.1.13.1 UNDER THE SUBSTEPPED SOLVER AND THE FIGURE MOVED: the same frictionless slider now retains **5.000002** m/s of 5 at f32, about FOUR ULP where the big-step solver left one. Same order, still negligible, still not acted on — but it grew with a solver change, which is precisely the signal this entry exists to catch. **Owner: M1.1.14**, where an ULP is the unit of the question; re-read it there against both precisions before assuming it is arithmetic again. +- **Frictionless-slider residual (since M1.1.11.1, QUALIFIED at M1.1.14)**: a FRICTIONLESS, undamped slider on a flat mesh seam retains more speed than it started with. Measured at M1.1.12 as `5.0000005` of 5 at f32 — one ULP — and **exactly `5` at f64**; that measurement is dated and stands. At M1.1.13.1 the f32 figure moved to **5.000002**, four ULP, under the substepped solver. **RE-MEASURED AT M1.1.14 AFTER THE FLOAT ENVIRONMENT WAS PINNED: it PERSISTS, unchanged at f32 to the bit, so the unpinned environment was NOT its cause** — the first branch of the alternative M1.1.14's brief imposed. f64 now shows **3 ULP** where M1.1.12 saw none. ~~The inference that used to settle this — "a solver adding energy would add it at both precisions", concluding arithmetic BECAUSE f64 was at zero — is DEAD, killed by that f64 figure.~~ It is replaced by a RELATIVE discriminant with nine orders of margin: energy injected at a physical rate is precision-independent in relative terms, so reproducing the f32 excess at f64 would take `4 × 2^29 = 2^31` ULP (ULP at 5.0 being `2^-21` at f32 and `2^-50` at f64), against **three** measured. It is ROUNDING at the solver's working precision, not energy. The f64 residual's CAUSE is deliberately NOT attributed: the TGS Soft port and M1.1.14's explicit folds both sit between the two measurements and neither was measured against this scene. Pinned in ULP — a metre bound cannot discriminate at f64 — in `mesh_test.zig`; normative detail in `engine-physics-solver.md` §1.7.2. - **Tooling facts have no owner (opened at M1.1.12)**: `engine-development-workflow.md` carries NO tooling-facts section, so these facts propagate by manual recopy from brief to brief with nobody accountable — which is how one gets dropped. Three were added this milestone, all self-reported, and one of them was a harness violating a fact the brief it was written against already listed. Give the workflow doc the section, and have briefs cite it instead of copying it. Not a physics milestone. +- **M1.D.8 — the ARM `zig build test` cache anomaly (opened at M1.1.14, measured, unattributed)**: `ubuntu-24.04-arm / ReleaseSafe` went from 61 s to **1402 s** of `zig build test` between two consecutive runs, `zig build` unchanged at 77 s. LOCATED and not guessed: **129 of 132 `compile test` steps RAN where the good run reused 131** — the cost is COMPILATION, not test execution, which is why it does not scale with precision and did not block the f64 axis. What collapsed the reuse is NOT established, and the obvious candidate is EXCLUDED by measurement: editing `mesh_test.zig` locally rebuilds 3 steps, not 129. 25 min of a 55-min budget, so it is a cost and not a failure. Owner: unassigned. +- **M1.D.9 — `bench.yml` keys its cache without the CPU axis (opened at M1.1.14)**: `zig-${os}-ReleaseSafe-${ZIG_VERSION}-${hashFiles}`, no `ZIG_CPU`, while `ci.yml`'s ten key lines gained one at M1.1.14 — and its own comment says it deliberately shares the scheme. It also builds with **no `-Dcpu` at all**, so it compiles for the runner's native CPU. `ARCH-031` rule 6 is unmet there. Pinning the key without pinning the build would record an axis it does not exercise; pinning the build would move the very numbers the benches report — which is why M1.1.14 measured it and left it. Owner: unassigned. +- **M1.D.10 — `weldengine/setup-zig` purges a cache that overlaps `.zig-cache` (opened at M1.1.14)**: its post step reports `Zig cache exceeded 2147483648 bytes (was N); purged contents before save`, and N tracks `.zig-cache` plus a little — 2 869 161 302 against 2 862 303 232 on windows, 4 000 990 476 against 3 803 447 296 on ubuntu, and 14 700 387 106 in a bench run. So two mechanisms cache overlapping trees and one purges its copy at 2 GB. **This is the mechanism behind the Windows `file_hash FileNotFound` corruption that cost six runs and two wrong hypotheses**; `ci.yml`'s own restore is intact, which is why runs are green, but whether the purged copy can ever win a restore is NOT established. It lives in another repository. Owner: whoever next opens `weldengine/setup-zig`. +- **Upstream Zig report — written, NOT filed (M1.1.14)**: `stage2_x86_64` lowers a 3-lane `f32` `@reduce(.Add)` as `p₁+(p₂+p₀)` where LLVM and the langref both give the sequential fold, identically under an explicit `@setFloatMode(.strict)` at all three optimisation levels. The two disagree on 31.4 % of random f32 triples by one ULP. Write-up at `briefs/artifacts/m1.1.14-zig-reduce-order-issue.md`, against master. **Filing is an outward-facing act and belongs to Guy.** The engine does not wait on it: `ARCH-031` rule 3 forbids float `@reduce` in source and a lint rule enforces it, precisely so reproducibility never rests on a compiler fix. +- **"1 milestone = 1 session" broke on M1.1.14, and the shape is worth naming (opened at M1.1.14)**: stated as a fact, not an excuse. A milestone whose OBJECT is the measuring apparatus absorbs, by fix-as-you-go, every defect it finds IN that apparatus — and the granularity rule of `engine-development-workflow.md` §2.2 does not account for that. M1.1.14 ran to two sessions, delivered FOUR behavioural changes where its frozen Scope named two, and carries five recorded deviations. Neither the scope nor the sessions were padded: each addition was arbitrated, and three of the four were prerequisites without which the measurement could not be taken at all. The question is whether the split rule needs a clause for instrument milestones. Decision of FORM, Guy's, taken cold and outside this milestone. +- **`ci.yml` carries six lines of FRENCH PROSE in comments (opened at M1.1.14, pre-existing)**: the + `changes` job's paths-filter rationale and the `ci-gate` aggregation note, all from `aad6894d`, an + ancestor of `main` — verified by blame, not assumed from age. Repo artifacts are strict English; a + verbatim spec citation is the exception and these are not citations. Found by the M1.1.14 closing + language audit, which is run with a Python regex and NOT a `grep` bracket class — the byte-wise class + reports a clean tree over files that demonstrably contain French. Outside this milestone's closed + correction list, so recorded rather than fixed. Owner: whoever next opens `ci.yml`. +- **M1.D.11 — a lint rule for `ARCH-031` rule 5's site set (opened at M1.1.14, deliberately NOT built)**: P1-3 measured the set at TEN — three thread creations and seven process entries — where the corpus said three, and it found the miss by reading whether each `Thread.spawn` sits inside a `test` block or a `fn`. That classification is mechanical and so is the rest of the derivation, so a lint rule could enforce it; **Guy's ruling is that it does not belong to this milestone and must not be written here.** What stands instead is the derivation recipe in the brief's Closing notes. Until the rule exists, a new thread or a new `main` can be added with no installation and nothing will say so. Owner: unassigned. +- **The float-environment assertion's WRAPPER is unwitnessed (opened at M1.1.14)**: every counter-factual in the tree perturbs the control register and then checks that `checkFloatEnvironment()` returns non-null — they witness the PREDICATE. No test observes the panic; there is no `expectPanic` or child-process harness anywhere. Blast radius refined by measurement rather than taken at first statement: a SIGN-INVERTED mutant would NOT survive, since it panics in every test reaching `World.init` or `runCanonical`; only the ALWAYS-TRUE mutant survives — `std.debug.assert(true)`, or deleting the call. Narrower than "any mutation", and still a real hole in the guard this milestone shipped. Found by the adversarial pass over the bit-neutrality verdict, outside the closed list of six, so recorded rather than fixed. Owner: unassigned. +- **`GroundState` is constant on the canonical scenario, so the character's VERDICT field carries no discrimination there (opened at M1.1.14)**: the steep ramps are barriers the character butts into, not surfaces it bears on, so the verdict is `.grounded` for all 1000 frames and what carries `cos_max_slope` into the witness is the POSITION. The field is serialised anyway — it is the authoritative controller output, it CAN vary, and its presence is pinned separately — but a reader should not take its presence for coverage of the `.on_steep_ground` and `.in_air` arms. Reaching those deliberately needs a surface the character stands ON while too steep to walk, which the bowl does not provide. Additive, no owner needed before the character surface lands at M1.1.15. - **Frozen pose setters are `void`, and pose writes are about to become allocation-fallible (opened at M1.1.12)**: `setBodyTransform`, `setLinearVelocity`, `setAngularVelocity` and `setCharacterPosition` are all `void` in the frozen interface. The character store already owns a broadphase proxy, so its writes go through `Broadphase.update`, which RESERVES and can fail. When M1.1.15 wires bodies into the broadphase, every pose setter faces the same thing. Two ways out — a reservation seam making `update` infallible, or error channels on the setters — and the decision belongs at M1.1.15, which IS the freeze, so this is not a post-freeze problem. Deciding it now would foreclose the better option. - **Should setters be fallible at all (opened at M1.1.12)**: the discriminant used this milestone is whether an entry RETURNS a value. It is uniform across the repo today. The question of whether a write that did not happen should be reportable spans the whole Tier 0 surface and belongs with the interface tier at M1.1.15, not inside a module milestone. - **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served depends on the scene's YAW — two partial cells of twenty-eight at f32, one partial and one exact freeze at f64. The CAUSE is named and measured: a face normal transported through a 90° yaw by a quaternion is not exactly perpendicular to a horizontal direction — it carries a residue of `3.3·floatEps` — so an exact `n · d >= 0` test admits a floor that cannot geometrically oppose horizontal travel, the iteration is spent, `slideAlongPlane` injects a vertical residue that drags the ceiling into the same filter, and two near-antiparallel normals send `slideAlongCrease` onto a cross product whose direction is pure noise. A noise band on `n · d` was written, measured to close all twenty-eight cells at both precisions, and REVOKED: it opened a tunnelling window on the DEFAULT path, penetration being `distance × |n·d|` and measured at `3.05e-5` over 32 m, growing without bound with distance. No band on that quantity can work, and the demonstration is algebraic rather than empirical: the transport residue tracks `floatEps(Real)` while a real grazing incidence is GEOMETRIC and does not, so they separate by nine orders at f64 and by a factor of 2.4 at f32 — the inseparability is structurally an f32 phenomenon. The length-dimensioned form escapes nothing: `coordScale ≈ d` makes it the dimensionless test with both sides multiplied by the distance. **And the residue varies by ARCHITECTURE and by COMPILATION MODE, not only by precision** — measured when `ubuntu-24.04 / Debug` refused a pinned cell: the `90° / base 0.05` cell reads `0.506016400` on arm64 macOS and `1.6999805` on x86-64 Debug, while `ubuntu-24.04 / ReleaseSafe` converges too. That is one more constraint on the unnamed cause and better than what preceded it. Two assertions that pinned a residual cell's VALUE and its frozen STATE were removed for that reason; what is asserted is the property the entry claims — no cell freezes — at `f32`, where four platform-and-mode combinations exercise it. At `f64` that property is FALSE on the only target that runs `f64`, `yaw 90 / base 0` returning `−0e0`. What remains open is upstream of the predicate: whether a path exists that does not DEGRADE the face normal of an axis-aligned quad under a yaw, rather than tolerating the degradation afterwards. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. - **The typed bus → Etch `EventStore` bridge has an owner and no implementation (opened at M1.1.13)**: M1.1.13 delivers a sensor STATE and two deltas and deliberately emits nothing — `forge_3d` imports no `weld_core`, a C1.1 exit metric. The translation into `TriggerEnter` / `TriggerExit` on the Tier 0 bus is M1.1.15's, and so is the piece underneath it: `World.event_bus` is comptime-typed while the interpreter keeps its own dynamic per-tick `EventStore`, the tree-walker cannot drive the former, and **no bridge exists between them — so no Etch rule can observe an event produced by Zig today**. That is a Tier 0 / Etch problem and not a physics one; `engine-phase-1-plan.md` names M1.1.15 as its owner by name. Recorded here so the sensor deltas are not read as an unfinished emission: the boundary is by design, and the state is the source of truth precisely because the bus drops its oldest entry on saturation. -- **The `f64` leg has never run anywhere but one machine (opened at M1.1.12)**: the CI matrix is `{ubuntu-24.04, windows-2025} × {Debug, ReleaseSafe}` at the DEFAULT scalar, and `-Dphysics_f64` appears NOWHERE in `.github/` — verified by grep, not assumed. So every claim this milestone and its predecessors make at `f64` — the five per-precision pins, the tolerance class, the freezes — rests on one target, one architecture and one compiler, and its greenness is the greenness of a single sample. The standing is not symmetric with `f32`, which four platform-and-mode combinations exercise, and the asymmetry is measurable: BOTH instances of "a measurement pinned as a property" found in M1.1.12 were caught by a DIFFERENT architecture on the only leg CI exercises, while the one assertion that pinned an `f64` state was never contradicted because nothing could contradict it. This is not a defect of M1.1.12 and is not charged to it. Owner: the next milestone that touches CI; M1.1.14 is the natural candidate, cross-platform determinism being its subject. - -## Non-negotiable rules - +- **~~The `f64` leg has never run anywhere but one machine~~ — CLOSED at M1.1.14.** `-Dphysics_f64` is now a matrix axis: `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}`, twelve cells, six of them f64 across three operating systems and two architectures. Verified by reading the printed `precision=f64` line on each of the six rather than by trusting the matrix label — the two are not the same claim. The cache key carries `f64_`, without which the two halves of the axis would thrash one archive, which is the mixed-CPU defect in a new guise. - **Zig version**: 0.16.x strict. Patch bumps (0.16.1, 0.16.2, …) are accepted transparently; minor bumps (0.17+) require a dedicated migration milestone with audit and CI green-light. The `build.zig` version guard panics if the running compiler's minor is not 16. - **No `@cImport`** outside generated `*_binding.zig` files for the 7 authorized C bindings. (Not yet enforced by linter — will be enforced by the M0.0 Zig linter milestone.) - **No `usingnamespace`** anywhere. Use explicit `pub const` re-exports. @@ -337,4 +353,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-13 +Last updated: 2026-08-17 diff --git a/bench/forge_3d_mesh.zig b/bench/forge_3d_mesh.zig index f0ade804..f34b6513 100644 --- a/bench/forge_3d_mesh.zig +++ b/bench/forge_3d_mesh.zig @@ -185,7 +185,7 @@ pub fn main() !void { rand.float(Real) * 2 - 1, rand.float(Real) * 2 - 1, }); - if (@reduce(.Max, @abs(raw.data)) == 0) continue; + if (raw.maxAbsComponent() == 0) continue; const unit = raw.scale(1 / raw.length()); const q = query.RayQuery{ .origin = unit.scale(60), diff --git a/briefs/artifacts/m1.1.14-zig-reduce-order-issue.md b/briefs/artifacts/m1.1.14-zig-reduce-order-issue.md new file mode 100644 index 00000000..e8647f7f --- /dev/null +++ b/briefs/artifacts/m1.1.14-zig-reduce-order-issue.md @@ -0,0 +1,79 @@ +# Upstream issue draft — `stage2_x86_64` reorders a floating-point `@reduce` + +> **Status: NOT FILED.** Filing is an outward-facing act and belongs to the repository +> owner. This file is the prepared text and its evidence; the reproducer it refers to +> is `briefs/artifacts/m1.1.14-zig-reduce-order-repro.zig`, self-verified. + +## Title + +`stage2_x86_64` does not honour the specified order of a floating-point `@reduce` + +## Toolchain, exactly + +`zig version` reports **0.16.0**. That is **not a public upstream release**: `ziglang/zig` +carries no `0.16.0` tag (the ref lookup 404s), its newest release is `0.15.1` and its +newest tag `0.15.2`, over 24 tags. So this is reported **against master**, not against a +release, and the compiler under test is whatever `0.16.0` build the distribution shipped +(Homebrew `zig 0.16.0_1`, labelled `stable` on its side). Host `aarch64-macos`, +cross-compiling; the defect is in the `x86_64` self-hosted backend, not in the host. + +## What the language specifies + +From the langref on **master** (verbatim, and identical at tag `0.15.2`): + +> Transforms a vector into a scalar value (of type E) by performing a **sequential +> horizontal reduction** of its elements using the specified operator + +> Note that `.Add` and `.Mul` reductions on integral types are wrapping; when applied on +> floating point types **the operation associativity is preserved**, unless the float mode +> is set to `Optimized`. + +The sequential fold of a 3-lane vector `p` is therefore `(p₀ + p₁) + p₂`. + +## What the two backends emit + +Both built at `-target x86_64-linux-gnu -mcpu=baseline`, from the same source file, on a +3-lane `f32` reduction: + +| backend | instruction sequence | fold | +|---|---|---| +| `stage2_llvm` (`-fllvm`) | `movaps` seeds `p₀`; `shufps $0x55` → `p₁`; `addss`; `unpckhpd`/`movhlps` → `p₂`; `addss` | `(p₀ + p₁) + p₂` ✅ | +| `stage2_x86_64` (`-fno-llvm`) | `movhlps` → `p₂`; `addss p₀`; `shufps $0x1` → `p₁`; `addss` | `p₁ + (p₂ + p₀)` ❌ | + +`p₁ + (p₂ + p₀)` is neither sequential in lane order nor an associativity-preserving +rendering of the sequential fold, so it contradicts both halves of the specified sentence. + +## It is not the float mode + +The sequence is **identical, instruction for instruction**, under the default float mode +and under an explicit `@setFloatMode(.strict)`, at `-O Debug`, `-O ReleaseSafe` and +`-O ReleaseFast` — six listings. That rules out a mis-propagated float mode as the cause, +and it rules out the specification's own `Optimized` escape. + +The reproducer places `probe()` and `expected()` in **one object, compiled by one backend +in one float mode**: `expected()` — which writes the fold in source — emits the plain +sequential `addss` chain that `probe()` does not. So the backend is able to produce the +specified order and does when the source states it. + +## Why it is observable + +Floating-point addition is not associative. The two orders disagree on **313 816 of +1 000 000** random `f32` triples drawn uniformly from `[-50, 50]` — **31.4 %** — by one +ULP. First witness `(-12.127813, 21.12078, -40.04462)`: `(p₀+p₁)+p₂` = `0xC1F869C9`, +`p₁+(p₂+p₀)` = `0xC1F869C8`. + +In the reporting project — a physics engine whose exit criterion is bit-exact replay — this +surfaced as the simulation state diverging between a Debug build and a ReleaseSafe build of +the same commit on the same machine, at the first stepped frame, amplifying to 2.53e-3 m +after 38 frames. At `f64` both backends produce `(p₀ + p₁) + p₂` (LLVM by +`mulpd`/`unpckhpd`, the self-hosted backend by a sequential `addsd` loop over memory), which +is why only the `f32` leg diverged. + +## Reproducing + +``` +zig build-obj repro.zig -target x86_64-linux-gnu -mcpu=baseline -O Debug -fllvm +zig build-obj repro.zig -target x86_64-linux-gnu -mcpu=baseline -O Debug -fno-llvm +``` + +then disassemble `probe` in each object and compare against `expected` in the same object. diff --git a/briefs/artifacts/m1.1.14-zig-reduce-order-repro.zig b/briefs/artifacts/m1.1.14-zig-reduce-order-repro.zig new file mode 100644 index 00000000..dc07e0be --- /dev/null +++ b/briefs/artifacts/m1.1.14-zig-reduce-order-repro.zig @@ -0,0 +1,46 @@ +//! Minimal reproducer — `stage2_x86_64` does not honour the specified order of +//! a floating-point `@reduce`. Prepared at M1.1.14, NOT yet filed upstream. +//! +//! The langref states that `@reduce` performs "a sequential horizontal reduction +//! of its elements", and that on floating point types "the operation +//! associativity is preserved, unless the float mode is set to `Optimized`". +//! The sequential fold of a 3-lane vector is `(v₀ + v₁) + v₂`. +//! +//! Build both ways and disassemble `probe`: +//! +//! zig build-obj repro.zig -target x86_64-linux-gnu -mcpu=baseline -O Debug -fllvm +//! zig build-obj repro.zig -target x86_64-linux-gnu -mcpu=baseline -O Debug -fno-llvm +//! +//! LLVM emits `(v₀ + v₁) + v₂`: +//! movaps seeds v₀ ; shufps $0x55 → v₁ ; addss ; unpckhpd → v₂ ; addss +//! +//! `stage2_x86_64` emits `v₁ + (v₂ + v₀)`: +//! movhlps → v₂ ; addss v₀ ; shufps $0x1 → v₁ ; addss +//! +//! That is a permutation AND a reassociation, so it contradicts both halves of +//! the specified sentence. Identical output under the default float mode and +//! under the explicit `@setFloatMode(.strict)` below, at `-O Debug`, +//! `ReleaseSafe` and `ReleaseFast` — six listings, instruction for instruction. +//! +//! Observable consequence, since float addition is not associative: the two +//! orders disagree on 313816 of 1000000 random f32 triples drawn uniformly from +//! `[-50, 50]`, by one ULP. First witness `(-12.127813, 21.12078, -40.04462)`: +//! `(v₀+v₁)+v₂` = `0xC1F869C9`, `v₁+(v₂+v₀)` = `0xC1F869C8`. +//! +//! Measured with Zig 0.16.0 on an `aarch64-macos` host, cross-compiling. + +/// The reduction under test. `@setFloatMode(.strict)` is explicit so the +/// specified "unless Optimized" escape cannot account for the difference. +export fn probe(a: *const [3]f32, b: *const [3]f32) f32 { + @setFloatMode(.strict); + const va: @Vector(3, f32) = a.*; + const vb: @Vector(3, f32) = b.*; + return @reduce(.Add, va * vb); +} + +/// The sequential fold the language specifies, for comparison in the same object. +export fn expected(a: *const [3]f32, b: *const [3]f32) f32 { + @setFloatMode(.strict); + const p: @Vector(3, f32) = @as(@Vector(3, f32), a.*) * @as(@Vector(3, f32), b.*); + return (p[0] + p[1]) + p[2]; +} diff --git a/briefs/m1.1.14-determinism.md b/briefs/m1.1.14-determinism.md new file mode 100644 index 00000000..4cdb7329 --- /dev/null +++ b/briefs/m1.1.14-determinism.md @@ -0,0 +1,3429 @@ +# M1.1.14 — Cross-platform determinism of forge_3d + +> **Status:** CLOSED +> **Phase:** 1.1 +> **Branch:** `phase-1/forge/determinism` +> **Planned tag:** `v0.11.14-determinism` +> **Dependencies:** M1.1.8 (islands, sleep, total resolution order), M1.1.11.1 (`MeshShape`, third term of the ordering key), M1.1.12 (kinematic character controller), M1.1.13 (sensors), M1.1.13.1 (TGS Soft solver — the final solver) +> **Opened:** 2026-08-14 +> **Closed:** 2026-08-16 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +`forge_3d` runs on its final rigid solver since M1.1.13.1, which is why this milestone comes now and not earlier: the bit-exactness contract validates one solver, once. This milestone delivers both levels of the determinism contract of `engine-phase-1-criteria.md` C1.1 — intra-ISA bit-exactness on 1000 frames, and inter-ISA parity of four discrete decisions on 60 frames — and it delivers them as a **reusable instrument**, because two exit criteria already written elsewhere depend on replaying it: M1.1.25 replays it at N workers, M1.A replays it on a rebuilt scheduler DAG. + +This milestone **measures and pins**. It does not redesign the solver, the islands, the sleep window, the sensors, or the eight query entries. It carries exactly two behavioural changes, both named below and both prerequisites rather than improvements: getting libm transcendentals off the deterministic path, and installing the floating-point environment instead of inheriting it. + +## Scope + +- **Deterministic transcendental substitution.** A deterministic cosine in `foundation/math/`, fixed operation order, no libm call, consumed by the `max_slope` conversion of the kinematic character controller. One function, one call site. +- **Floating-point environment installed, not assumed.** Round-to-nearest-even, denormals preserved (FTZ and DAZ off) installed by the platform layer at thread spawn and at platform-layer init for the main thread; asserted — never reinstalled — at the physics entry point. +- **CPU feature set pinned on every CI cell**, including the cells that exist today. No cell compiles for its native CPU. +- **Determinism harness as an instrument**, reachable by `zig build forge-determinism`, running the canonical scenario below at **one worker**, producing three artifact kinds (below), self-reproducible across two consecutive runs on the same machine. +- **Three committed witness kinds**, generated on x86_64 and committed in tree. +- **Level 1 green**: 1000 frames, per-frame hash chain identical between `ubuntu-24.04` and `windows-2025`, for `{f32, f64} × {Debug, ReleaseSafe}`. +- **Level 2 green**: `ubuntu-24.04-arm` cell added; the four discrete traces identical to the x86_64 witness over the first 60 frames, both precisions; continuous divergence frame measured, reproducible across runs, and recorded. +- **Two CI matrix axes added** (`-Dphysics_f64`, `ubuntu-24.04-arm`) and the harness placed on the normal CI path so later milestones replay it without opting in. +- **Qualification of the M1.1.13.1 residual**: the frictionless slider retaining `5.000002` of `5 m/s` in f32, re-measured after the float environment is pinned, with a stated conclusion (see Acceptance criteria). + +## Out of scope + +- **Level 3 — inter-ISA bit-exactness.** Explicitly out of Phase 1 (C1.1). Nothing in this milestone promises it, and no test asserts it. +- **N-worker replay.** Resolution is sequential until M1.1.25; the harness is written to be replayable at N workers and is run at one. Adding threading here is out of scope. +- **A general in-house libm.** `ARCH-031` admits deterministic substitutes function by function on demonstrated need. This milestone needs one. Writing more, or building a libm replacement, is an abuse of that clause. +- **`@sqrt`.** IEEE-754 requires it correctly rounded and it lowers to a hardware instruction. It is not a transcendental for this purpose and is not touched. +- **Any solver, warm-start, island, sleep, sensor, controller-algorithm or query-semantics change.** The two behavioural changes in Scope are the complete list. In particular: if the non-pruning of the retained-pair set turns out to live in the engine rather than in the test scaffold, that is a STOP (see Gate B), not a fix taken here. +- **Widening the `f32` precision boundary of the public surface** (`engine-physics-queries.md` §1.11.8). A single decision belonging to the freeze milestone. +- **`actions/download-artifact` and any aggregator job.** Refused with its motive in `engine-development-workflow.md` §7.3. Each cell self-verifies. +- **CPU affinity as a determinism instrument.** With a total resolution order, no result depends on interleaving; affinity acts on measurement noise only (`engine-platform.md` §4 — *Threading*). +- **`foundation/math/conversions.zig` and the `asFloatSlice` family.** Owed to Kinesis M1.2 (`engine-simd.md` §4, named open point). + +## Specs to read first + +Order follows ownership. + +1. `engine-phase-1-criteria.md` — **C1.1**, determinism block in full: the three levels, the four discrete invariants verbatim, `K = 60`, the divergence frame defined as the first frame where per-body deviation exceeds `1e-4 × body scale`, the one-worker boundary of level 1, level 3 out of Phase 1. +2. `engine-invariants.md` — **`ARCH-031`** (six rules and the boundary it refuses to promise), `ARCH-022` (conventions, disjoint from execution semantics), `ARCH-010` (parallelism, hence per-thread float state). +3. `engine-platform.md` — **§4 — *Threading*** (float state contract of `spawn_thread`), **§8 — *CI du dépôt moteur*** (matrix, three pinning axes, witnesses, why there is no aggregator). +4. `engine-coordinate-system.md` — **§2 — *Types mathématiques (Tier 0)*** (`Vec(N, T)` over `@Vector`, reduction order, deterministic transcendentals, layout consequence). +5. `engine-physics-solver.md` — **§1.7.1** (contact iteration order, no hashed containers), **§1.8.1** (total key `(island rank, pair_key, subshape_id)`, union-find), **§1.8.3** (sleep window computed without trigonometry, and why), **§1.8.7** (pair retention *is* the wake graph), **§1.8.8** (parallelism belongs to M1.1.25), **§1.13.11** (sensor determinism). +6. `engine-physics-queries.md` — **§1.11.6** (selection and determinism), **§1.11.8** (precision boundary — read to confirm it is untouched), **§1.12** (kinematic controller model, where `max_slope` lives). +7. `engine-phase-1-plan.md` — line **M1.1.14**, line **M1.1.25**, section **M1.A** (the two downstream consumers of the harness). +8. `engine-development-workflow.md` — **§7.3** (marketplace action whitelist, and the recorded refusal). + +## Files to create or modify + +- `src/foundation/math/` — **create** — deterministic cosine, fixed operation order, no libm, no FMA. Proposed name `trig.zig`; if `foundation/math/` already has a home for scalar functions, use it and record the choice in the execution log. +- `src/core/platform/` — **modify** — thread-spawn path and platform-layer init: install the float environment (MXCSR bit 15 and bit 6 on x86_64, FPCR bit 24 on AArch64), plus a readable accessor so a consumer can assert rather than reinstall. +- `src/modules/forge/forge_3d/character.zig` — **modify** — replace the `@cos` of the `max_slope` conversion by the deterministic cosine. The controller algorithm is unchanged; this is a scalar-conversion substitution. +- `src/modules/forge/forge_3d/tests/determinism/` — **create** — harness: canonical scenario, binary state serialization, per-frame hash chain, the four discrete traces, the reference window, witness comparison, divergence-frame measurement. +- `src/modules/forge/forge_3d/tests/determinism/witnesses/` — **create** — the committed witness files (naming below). +- `build.zig` — **modify** — `forge-determinism` step; confirm `-Dcpu` is exposed and reachable from CI. +- `.github/workflows/ci.yml` — **modify** — matrix: add the `-Dphysics_f64` axis and the `ubuntu-24.04-arm` cell; add explicit `-Dcpu` to **every** cell including existing ones. + +Files outside this list are not to be touched without a round-trip. + +## Acceptance criteria + +### Canonical scenario — frozen + +Fixed step 60 Hz, `substep_count` at its M1.1.13.1 default, 1000 frames, one worker, **no RNG anywhere**: every initial value is a literal. Body creation order is part of the contract, because `BodyId` allocation order determines the island rank and therefore the resolution order. Contents, each element present for a named reason: + +- a static half-space ground — the surface everything rests on; +- the five-box stack of M1.1.13.1 — manifold cardinality 4, sleep transitions, pair retention; +- two groups starting apart and colliding partway through — island partition change, in both directions; +- the frictionless slider of M1.1.13.1 — carries the ULP residual into the instrument; +- a sphere sliding across the internal edges of a static `MeshShape` — several constraints per pair, hence the third term of the ordering key (M1.1.11.1); +- one sensor and one body entering and leaving it — sensor state and enter/exit deltas (`engine-physics-solver.md` §1.13.11); +- one kinematic character on a scripted path over a step and a slope — the controller, and the site where a wrong `max_slope` conversion would surface first. + +The half-space is intentionally present and intentionally excluded from the continuous metric: the deviation metric covers **mobile bodies only**, weighting translation and rotation by the body radius. A static shape with an unbounded local AABB has no meaningful scale and must not be forced into a fabricated one. + +### Witnesses — three kinds, frozen keys + +- `continuous-chain--.bin` — 1000-frame hash chain over the canonical binary state dump. x86_64 only. **Pass/fail** — this is level 1. Keyed by optimize mode because Debug/ReleaseSafe equality under `.strict` is a hypothesis, not a given; keying it makes the hypothesis a test rather than an assumption. +- `discrete-.bin` — the four discrete traces over 60 frames, **raw, not hashed**. Compared by every cell, ISA included. **Pass/fail** — this is level 2 point 1. Keyed by precision only: the traces are derived from integers and are ISA-independent by construction, so a mismatch between two cells of the same precision is a finding, whichever axis it comes from. +- `reference-window-.bin` — 60 frames of raw mobile-body poses, produced on x86_64. Read by the ARM64 cell to compute the divergence frame. **Measurement only** — its value is recorded, its regression is the signal. + +Regeneration of any witness is a declared act in the PR body, with its motive. + +### Tests + +- `src/modules/forge/forge_3d/tests/determinism/` — `test "harness is self-reproducible"` — two consecutive in-process runs of the canonical scenario produce byte-identical outputs of all three kinds. +- `test "continuous chain matches committed witness"` — the 1000-frame chain equals `continuous-chain--.bin`; on mismatch the failure names the first differing frame index. +- `test "four discrete traces match committed witness"` — island partition, sleep transitions, per-pair manifold cardinality, and retained pair set all equal `discrete-.bin` over 60 frames. +- `test "retained pair set shrinks in the canonical scenario"` — **non-vacuity probe.** The fourth trace is only an oracle if the set can lose members; this test asserts it does, at least once, in this scenario. Without it the fourth trace passes by accumulation and proves nothing. +- `test "divergence frame is reproducible"` — the measured frame is identical across two runs on the same machine. +- `test "physics entry point asserts the float environment"` — a deliberately perturbed float state is detected at the entry point and reported, not silently reinstalled. +- `test "deterministic cosine is bit-stable against a committed value table"` — a table of inputs covering small values, values near π/2, large arguments, and one denormal, compared bit-for-bit against committed expected bits. +- `test "no external transcendental symbol on the deterministic path"` — mechanical inventory over the emitted assembly of the three targets, asserting zero call to `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `pow`, `exp`, `log`, `fmod`, `hypot`, `cbrt`. Anchor the pattern on the instruction mnemonic at line start; do not use `\b`, which is a GNU extension and silently matches nothing on BSD grep. Name any tool substitution. + +Floor: the 527 existing `test` blocks of `forge_3d` stay green with no re-baseline, at both precisions and both optimize modes. + +### Benchmarks + +None targeted. The float discipline has an accepted cost on reductions and on the substituted cosine; if `bench/physics_forge_3d_integration.zig` regresses beyond 5 %, report the figure in the closing notes rather than tuning against it — the discipline is not negotiable per case. + +### Observable behavior + +- `zig build forge-determinism` runs the canonical scenario and prints, on one line each: the chain verdict, the four trace verdicts, and the divergence frame. +- The same command with a witness deliberately corrupted fails naming the frame index, not merely "outputs differ". +- The M1.1.13.1 slider residual re-measured after Gate A, with a stated conclusion: either it persists — then it is characterised against the solver's own arithmetic and recorded as a measured property in `engine-physics-solver.md` — or it disappears, in which case its cause was the unpinned float environment, which is this milestone's own subject. Both are conclusions. "Still under investigation" is not. + +### CI + +- `zig build` clean, zero warnings, on the full matrix +- `zig build test` green on `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` +- `zig fmt --check` green +- `zig build lint` green +- `commit-msg` hook green on every commit of the branch +- every cell carries an explicit `-Dcpu`; no cell compiles for its native CPU +- `zig build forge-determinism` is on the normal CI path, not behind a flag +- no action outside the §7.3 whitelist appears in the workflow + +## Gates + +Gate-by-gate STOP/GO. Review is on the pushed diff, never on a summary. Fix-as-you-go: a gap found at any gate is closed inside this milestone. + +**Gate A — float environment and libm removal.** Deterministic cosine; `@cos` out of the controller; float state installed by the platform layer and asserted at the physics entry point; `-Dcpu` added to the existing cells. Exit: the assembly inventory reports zero external transcendental on the three targets; the environment assertion is green on the three targets; 527 tests green. + +**Gate B — the harness as an instrument.** `zig build forge-determinism`, canonical scenario, the three artifact kinds, self-reproducibility, one worker. Exit: two consecutive runs byte-identical, and the non-vacuity probe on the retained pair set green. +*Named STOP condition:* the retained pair set is reported as never pruned. Determine where that lives. Test scaffold — fix it here, it is the instrument. Engine retention — **STOP and return**: pair retention is the wake graph (`engine-physics-solver.md` §1.8.7) and changing it is an islands-and-sleep behaviour change, out of scope by the list above. + +**Gate C — level 1.** Witnesses generated on x86_64 and committed; chain identical between Linux and Windows for the four `(precision, mode)` keys. Exit: four green cells per OS, no re-baseline anywhere else. + +**Gate D — level 2.** `ubuntu-24.04-arm` cell; the four traces identical to the x86_64 witness over 60 frames at both precisions; divergence frame measured, reproducible, recorded. Exit: discrete parity green, divergence frame stated as a number. +*Not a STOP:* a continuous divergence on ARM64, whatever its frame. That is what level 2 predicts and what level 3 would be needed to remove. `faddp` is likewise not a STOP — see Notes. + +**Gate E — CI and residual.** Full matrix green; harness on the normal path; slider residual re-measured with its conclusion; spec patch content for `engine-physics-solver.md` produced if the residual is to be recorded there. + +**Gate F — closure.** `CLAUDE.md` §3.4 patch **inside the PR**, not after merge: current-state table, tags table, open decisions, last-updated date. Audits, brief closure, PR opened. Squash message and tag annotation come from Claude.ai; merge and tag are Guy's. + +## Conventions + +- **Branch:** `phase-1/forge/determinism` +- **Final tag:** `v0.11.14-determinism` +- **PR title:** `Phase 1 / Forge / Cross-platform determinism of forge_3d` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md` §4.3). Subject ≤ 72 characters, measured before use. +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md` §4.6) + +## Notes + +**Measured at recon — do not re-derive these, and do not treat any of them as open.** + +- ~~`@reduce(.Add)` has exactly four sites: `foundation/math/vec.zig:77` (dot), `:94` (lengthSquared), `foundation/math/aabb.zig:109`, `forge_3d/mesh.zig:930`. On AArch64, Zig 0.16 emits `faddp`; the resulting sequence is `(p0+p1)+p2(+p3)`, the same left fold as the x86_64 scalar chain. Adding two lanes with one instruction is the same IEEE addition as adding them with two. **The marker falls because the sequence is ordered, not because no pair instruction is emitted** — record the reason, not the instruction count, or the next milestone reading an assembly listing will reopen it. A **total** reduction (`faddv`, `haddps`) would violate `ARCH-031` rule 3.~~ **REFUTED BY MEASUREMENT AT GATE C — see the 2026-08-14 18:10 entry.** The four sites are right and the AArch64 reading is right. The clause "the same left fold as the x86_64 scalar chain" generalised **one** backend's listing to the target, and the criterion built on it — *ordered is enough* — is the wrong criterion: only *the same order across backends* is. Zig's self-hosted x86_64 backend folds a 3-lane f32 reduce as `p1 + (p2 + p0)`, which is ordered, is not a total reduction, and violates rule 3 exactly as a `haddps` would. +- ~~`@reduce(.Add)` has exactly four sites~~ **REFUTED AT GATE C.** True for `.Add` and blind to `.Min`/`.Max`, which carry the same order question **plus** NaN propagation: the repository holds 44 `@reduce`, of which 26 are boolean and 2 integer — exact, out of scope — and **18 are float**, twelve in production and six in tests and benches. Sweeping the known list would have left fourteen. The class is not the list. +- FMA contraction is absent in ReleaseSafe and in ReleaseFast, with AVX2/FMA available. `ARCH-031` rule 2 holds it as a contract regardless: the invariant rests on `.strict`, not on a measurement taken one day. +- `@cos` lowers to an external `cosf` on Linux, Windows and AArch64. This, not the reductions, is the real level-1 risk — and not because trigonometry is impure: `cos(max_slope)` is **stored engine state** derived from libm, so it sits in the compared bits from frame 0, and the Windows and Linux implementations need not agree to the last bit. +- `-Dphysics_f64` has existed since M1.1.0 (`build.zig:127`); the 527 forge tests pass at both precisions in ReleaseSafe. The plan line that still listed f64 as an M1.1.15 deliverable was stale and has been corrected. This is why the f64 CI leg lands here and not later. +- `MoveResult` is deliberately the internal type behind the public alias `CharacterMoveResult` (`forge_3d/root.zig:252`). There is no interface divergence and nothing to reconcile before the M1.1.15 freeze. +- `weldengine/setup-zig` already maps `arm64 → aarch64` and has an explicit test for it (`version.ts:39`, `version.test.ts:104`). The ARM64 cell needs no action bump. +- arm64 hosted runners are generally available, not a preview: public repositories since 2025-08-07 (4 vCPU, no cost), private repositories since 2026-01-29 (2 vCPU, counted against the plan's minutes). + +**Failure mode to watch, named.** *An assertion valid only through a tacit property of its fixture.* The precedent is `sleep_test`, where a strict inequality held only because the scene contained nothing but boxes. No grep finds this class; only asking, of every oracle, "what makes this true here and not elsewhere?". At one ULP across four platforms it is the most expensive failure mode available, and the retained-pair non-vacuity probe exists precisely because the fourth trace was about to be an instance of it. + +**Class exhaustion.** Any instrument fix carries its sweep, with the pattern used, who ran it, and both counts. The pattern must verify itself: corpus and code comments wrap around 95 columns, so a line-by-line grep is blind to any claim straddling two lines — two review rounds were lost to that at M1.1.13.1. `grep -P` does not exist on BSD; name every tool substitution. + +**Observation, not a task.** `engine-directory-structure.md` shows the character controller at `extensions/character_3d.zig` and does not list `forge_3d/character.zig` or a `forge_3d/tests/` directory, while `engine-phase-1-criteria.md` names the latter normatively. The two layers are legitimate — ECS-facing extension over internal kinematic model — and the tree is abridged by design. Flagged so it is not mistaken for a path error mid-milestone; reconcile only if the milestone otherwise touches that diagram. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* + +- [x] `engine-phase-1-criteria.md` (C1.1) — read 2026-08-14 08:47 +- [x] `engine-invariants.md` (`ARCH-031`, `ARCH-022`, `ARCH-010`) — read 2026-08-14 08:50 +- [x] `engine-platform.md` (§4 *Threading*, §8 *CI du dépôt moteur*) — read 2026-08-14 08:48 +- [x] `engine-coordinate-system.md` (§2) — read 2026-08-14 08:46 +- [x] `engine-physics-solver.md` (§1.7.1, §1.8.1, §1.8.3, §1.8.7, §1.8.8, §1.13.11) — read 2026-08-14 08:51 +- [x] `engine-physics-queries.md` (§1.11.6, §1.11.8, §1.12) — read 2026-08-14 08:52 +- [x] `engine-phase-1-plan.md` (M1.1.14, M1.1.25, M1.A) — read 2026-08-14 08:53 +- [x] `engine-development-workflow.md` (§7.3) — read 2026-08-14 08:55 + +## Execution log + +**2026-08-14 08:45–08:56 — setup.** Branch `phase-1/forge/determinism` off `main` at `5259344` +(M1.1.13.1 is merged; `CLAUDE.md`'s current-state table still says "PR open" and is patched at +Gate F). Brief copied verbatim, eight specs read in full, brief activated. + +**2026-08-14 09:00 — Gate A recon, measured.** Production transcendentals in `forge_3d`: exactly +ONE, `character.zig:1422` `@cos(max_slope)` — the brief's named site, confirmed. `quat.zig` +`fromAxisAngle` carries `@sin`/`@cos` but has NO production call site (all eleven references are +tests), so it is not on the deterministic path and is not touched. The platform layer has no +`spawn_thread` of its own — `threading.zig` is FROZEN at C0.5 and propagates `std.Thread` as-is — +so the engine's thread-spawn path is `core/jobs/scheduler.zig:workerMain`, the site `ARCH-031` +rule 5 names by role. + +**2026-08-14 09:05 — file placement, two choices recorded as the brief invites.** +(a) The deterministic cosine went to `src/foundation/math/trig.zig`, the name the brief proposed: +`foundation/math/` has no existing home for scalar functions (`exact.zig` is integer arithmetic +for triangles, not a scalar-function file). Re-exported as `math.cos` + `math.max_trig_argument`. +(b) The float environment went to **`src/foundation/float_env.zig`**, NOT under `core/platform/`, +and `src/core/platform/float_env.zig` is a facade over it. Reason: the two halves of `ARCH-031` +rule 5 have different owners — the platform layer INSTALLS, every compared module ASSERTS — and +the module that must assert is `forge_3d`, which may not import `weld_core` (a C1.1 exit metric). +Splitting reader and writer across two files would put one register layout in two places, the +drift shape this repo has already paid for once (`contactMargin`). One owner, two callers. +Consequence on the build graph: `core_module` gains a `foundation` import (acyclic — `foundation` +imports nothing but std). + +**2026-08-14 09:10 — files outside the brief's list, each with its reason.** +`src/foundation/float_env.zig` and `src/foundation/root.zig` (placement above); +`src/core/jobs/scheduler.zig` (+6 lines: `float_env.install()` at the head of `workerMain` — the +thread-spawn path the brief names, which does not live in `core/platform/`); +`src/runtime/main.zig` and `src/editor/main.zig` (+1 call each: the main thread is not born of a +spawn and the platform layer has no `init()` today); +`src/modules/forge/forge_3d/determinism.zig` (the module's assert seam) and `root.zig` (its +re-export); `src/modules/forge/forge_3d/tests/solver_test.zig` (+1 call: `World.init` is today's +tick driver, i.e. today's physics entry point); `tools/asm_inventory/` (the inventory scanner). + +**2026-08-14 09:15 — the cosine, measured not asserted.** Cody-Waite 3-part reduction + +degree-13/14 minimax kernels, Horner, internal `f64` for both scalars, integer part by +magic-constant add (so no `@floor` — which lowers to an external `floor` on baseline x86_64). +Domain `|x| <= 2^20·π/2 ≈ 1.647e6`, which is the EXACTNESS limit of the reduction and is asserted +rather than clamped; serving past it needs Payne-Hanek, i.e. the replacement libm `ARCH-031` +rule 4 names as an abuse of its own clause. Accuracy MEASURED, and stated as a measured maximum +with no rounding qualifier: worst absolute error `1.110e-16` on a 4096-point oblique sweep, half +the spacing of `f64` at unity; `2.123e-15` (~9.6 ULP) just inside the domain edge where the +reduction is weakest; and the `f32` result is bit-identical to the `f64` result narrowed, on every +probe. Bounds set at 4 ULP and ~5× the measurement respectively, because the oracle is `@cos` and +the oracle's own answer moves by an ULP between C libraries. **Accuracy is not the contract** — +binary identity between platforms is, and its oracle is the committed value table compared bit for +bit (Gate B). At 1e-15 on a slope threshold the function is a dozen orders past what its one +caller needs. + +**2026-08-14 09:20 — the x86_64 float-environment arm cannot be validated on this machine, and +that is measured.** An `x86_64-macos` build of the `float_env` tests runs under Rosetta 2, which +does not emulate `MXCSR`: the raw `stmxcsr` returns a constant `0x0000` — not a value real +hardware can hold, since the ABI leaves the six exception masks at `0x1F80` — and every write to +the rounding-control field is dropped. `install()` still takes effect at the arithmetic level (an +`FTZ` write does flush a denormal), so the WRITER is partially witnessed and the READER is not +witnessed at all. The discrimination test is what made this visible rather than silent: it FAILED +there instead of passing vacuously. Recorded in the file header; the x86_64 arm is validated on CI +cells and nowhere else. + +**2026-08-14 09:22 — a fragile probe found by that detour and hardened.** +`std.mem.doNotOptimizeAway(&x)` does NOT stop the constant-folding of a float product: on the +`x86_64` / ReleaseSafe build — a CI cell — the weak form yielded the folded denormal for one use +of the expression and the flushed zero for a comparison against `0.0` in the SAME statement, i.e. +two values for one product. The denormal probe now loads both operands and stores the result +through `volatile` pointers. On native aarch64 both forms agree in all three optimize modes, so +the weak form would have shipped green here and been a coin flip on the leg that matters. + +**2026-08-14 09:25 — one claim corrected at the point of writing.** A comment in +`forge_3d/determinism.zig` said the perturbation went "through the owner's own installer rather +than by hand" while the code below it reached for the register layout directly — a text asserting +more than its code does. Fixed at the source rather than in the comment: +`foundation/float_env.zig` gained `save` / `restore` / `installState`, `install()` became +`installState(engine_default)`, and the consumer now perturbs without holding a second copy of the +layout. `installState` carries its own round-trip test over all four rounding modes, which is the +WRITE-direction half of the x86-vs-AArch64 encoding swap that the read test already pinned. + +**2026-08-14 09:30 — the assembly inventory, and the two ways it was nearly vacuous.** Realized as +`zig build forge-asm-inventory` + `tools/asm_inventory/`, a Zig scanner rather than `grep` — the +named tool substitution the brief requires, chosen because anchoring on the mnemonic wants `\b`, +which is a GNU extension that silently matches nothing on BSD grep; a scanner removes the class +instead of dodging one instance. Two vacuity traps hit and closed, both caught by the scanner's own +"call sites examined" counter rather than by inspection: (1) compiling `forge_3d/root.zig` directly +emits NOTHING — it is a re-export file and Zig is lazy — giving `0 call sites examined` on all +three targets; (2) forcing references with `_ = &f` forces ANALYSIS but not CODEGEN, giving the +same `0` with a 37 000-line listing of debug records and exactly one emitted function. What works +is taking the addresses at RUNTIME into a global the exported anchor returns. +**Result: 41 416 call sites examined across the three targets, zero external transcendental.** +**Counter-factual (the object, not the constant): `@cos` reinstated in `character.zig` → +`call cosf@PLT` (x86_64-linux), `call cosf` (x86_64-windows), `bl cosf` (aarch64-linux), one per +target, each named with file and line; `compilation errors` grep = 0, so it is a measurement.** +Restored and re-verified green. + +**2026-08-14 09:40 — `-Dcpu` on every cell.** `ZIG_CPU: baseline` at workflow level, applied to all +twelve `zig build` invocations of `ci.yml`. `baseline` rather than a named model: it is defined per +architecture by the compiler, so one value is correct on x86_64 and aarch64 alike and cannot drift +with a runner refresh. Full suite green at `-Dcpu=baseline`: 266/266 steps, 1721/1738 tests +(17 skipped). The `forge-asm-inventory` step is wired on one cell — it answers a property of the +three TARGETS, not of the host asking — mirroring `verify-synth-100`. + +**2026-08-14 09:50 — the inventory's SCOPE measured, not just its verdict.** 818 emitted functions +and 629 distinct symbols in the x86_64-linux listing, spanning `body_manager`, `character`, +`determinism`, `mesh`, `pipeline`, `query`, `rigid`, `shape`, `slot_alloc` and `root` — every +`forge_3d` subsystem. A verdict of "zero found" over an unmeasured surface is the same shape as the +two vacuity traps above, so the surface is reported. + +**2026-08-14 09:55 — two false negatives found in the scanner by self-review and closed.** The +operand was read as ONE symbol, which serves `call cosf@PLT` and silently misses +`call qword ptr [rip + cosf@GOTPCREL]` — the form `-fno-plt` produces, i.e. exactly the build flag +a distribution is most likely to add. And only one leading underscore was stripped, so a C +library's internal `__sinf` alias would have passed. The operand is now TOKENISED and every token +tested; matching stays exact against thirteen names, so registers, size keywords and address +arithmetic cannot false-positive — pinned by three new negative cases alongside the three new +positive ones. Re-verified: same 41 416 call sites, still zero, and the real counter-factual still +fires with all three spellings. + +**2026-08-14 09:45 — Gate A exit measured.** `forge_3d` 527 → 530 tests (the three new +`determinism.zig` cases), no re-baseline anywhere. Full suite 1721/1738. `zig build`, +`zig fmt --check`, `zig build lint` green. + +**2026-08-14 10:20 — Gate A REVIEW: STOP, one blocker and four fixes. B1 — the placement violated +the metric it invoked.** My paraphrase of the C1.1 dependency bound was "not `weld_core`"; the +metric, in block *Métriques*, is a WHITELIST OF EXACTLY TWO — `src/foundation/math/`, and the +public ECS components of `src/modules/forge/api/`. `src/foundation/float_env.zig` is neither, so +`forge_3d` was gaining a THIRD foundation dependency, outside the list. Fixed: the single +definition moved to `src/foundation/math/float_env.zig`, one of the two whitelisted entries, and +`src/core/platform/float_env.zig` is DELETED — a file whose only content is a re-export across a +tier boundary adds a name without adding a definition. Tier 0 now imports the definition directly +at its call sites — **"its three call sites" as written here was true on 2026-08-14 and is struck: +P1-3 measured the set at ten, three of them inside modules.** `ARCH-031`'s own *Sources de vérité* +line splits the same way +(`engine-coordinate-system.md` §2 for execution semantics, `engine-platform.md` for the FPU state), +which is corroboration rather than the argument. + +**2026-08-14 10:25 — R1 was already delivered, and I said it badly.** The `forge-asm-inventory` +step has been in `ci.yml` since commit `b7915b3`, gated on `ubuntu-24.04 / Debug` — one cell, three +targets, exactly as the review asks. What my Gate A report said was that it is not a case inside +`zig build test`, which reads as "not in CI" and is not what I meant. It is on the CI path. + +**2026-08-14 10:30 — R2, and the review is right on both counts.** Every "correctly rounded" claim +about `cos` is removed from the doc comments, the tests and the journal. A 0.5-ULP ABSOLUTE error +cannot establish correct rounding for a function whose image crosses zero — near a zero the +correctly rounded result demands an absolute error orders of magnitude smaller — and the +`2.123e-15` measured at the domain edge contradicts the claim outright at ~9.6 ULP. The maxima +stand as measured maxima. The contract restated where it belongs, in the accuracy test itself: +BINARY IDENTITY between platforms, oracle = the committed value table compared bit for bit. + +**2026-08-14 10:35 — R3, and the measurement is sharper than the note that asked for it.** At +`-Dcpu=baseline`, on a two-line probe: **aarch64** turns `@mulAdd` into `fmadd d0, d0, d1, d2` — +`FMADD` is in the ARMv8-A base ISA, so the backend COULD contract and does not; **x86_64** turns it +into `jmp fma@PLT`, an external TAIL CALL, FMA3 being Haswell and later so baseline has no fusion +instruction at all. So `ARCH-031` rule 2 is genuinely exercised on ONE target of three and holds on +the other two by absence of hardware. That also makes the zero-fused-instruction count over +`forge_3d`'s three listings non-vacuous rather than a possibly-wrong grep: 0 on all three, and the +probe proves the pattern fires. Written in the scanner header and in Closing notes. One corollary +recorded there too: `fma` is not among the thirteen names, so the inventory would NOT flag that +external call — correctly, rule 2 admitting an explicit `@mulAdd` as a declared exception at its +site. + +**2026-08-14 10:45 — R4, checked on the diff and true.** Five blocks rendered one verdict over +several INDEPENDENT claims. Split where the cases exercise different MECHANISMS, kept as one block +where they sample one mechanism — and there the failing case is now NAMED, which a bare +`expectEqual` in a loop never was. `asm_inventory`: one block of eighteen cases became four +(direct-call symbol resolution / tail-call mnemonic table / indirect GOT tokenising / underscore +stripping) plus two negatives (exact-match rejection / tokeniser inventing no symbol) plus the +anchor block. `trig.zig`: `cos(0) = 1` and evenness separated. `float_env.zig`: rounding decode, +flush decode, the x86-only split DAZ/FTZ state, and `install` repairing — four blocks where there +was one. `determinism.zig`: DETECTED and NOT-REPAIRED separated, since a self-healing check would +pass the first and fail only the second. Test counts: `asm_inventory` 5 → 9, `trig` 7 → 8, +`float_env` 5 → 8, `determinism` 3 → 4. + + +**2026-08-14 11:10 — FIRST MATRIX RUN, and it went red on `ubuntu-24.04 / Debug` at `zig build`. +The defect was real, my local proof of it was vacuous, and the vacuity is the more useful half.** + +`invalid constraint: '*m'`. The two `MXCSR` accessors never compiled on x86_64 in Debug — and the +check I had run before pushing, `zig build-obj -target x86_64-linux src/foundation/float_env.zig`, +returned 0 while analysing NOTHING: as a root module with no export and no test, Zig's lazy +analysis never reached the private `readMxcsr` / `writeMxcsr`. It is the same class as the two +empty-listing traps the inventory caught earlier in this gate, this time in my own verification +routine rather than in a deliverable. **The faithful local check is `zig build -Dtarget=` +over the whole graph** — what CI actually runs — or a probe that `export`s the functions. + +Three facts established by disassembling emitted objects, none of them by reading an exit code: + +1. **Zig 0.16 uses two different backends for x86_64 and they do not accept the same inline + assembly.** `zig build` in Debug goes through the SELF-HOSTED x86 backend; ReleaseSafe and + ReleaseFast go through LLVM. `"*m"`, `(%[p])` with an `"r"` pointer, `0(%[p])` and `(%rsp)` are + all correct under LLVM and all rejected by the self-hosted backend. `"+m"` crashes LLVM outright. +2. **Worse than a compile error: `"m"` as an INPUT is accepted by both and means different things.** + The self-hosted backend loads the VALUE; LLVM materialises a POINTER into a stack slot and hands + the instruction that slot, so `ldmxcsr` would have configured the FPU from the low half of a + stack ADDRESS — in ReleaseSafe and ReleaseFast, i.e. in every shipped build, silently. A form + that compiles everywhere and is garbage in the mode that ships is the most expensive shape + available, and it is why the constraint choice is now argued in the file rather than in a commit + message. +3. **`-fno-emit-bin` skips inline-assembly validation entirely** — a third way to get a green + verdict from a check that verified nothing. + +What ships is `"=m"` as an OUTPUT on both instructions, the only pair accepted AND correct on both +backends. For `stmxcsr` that is its true direction; for `ldmxcsr` it is a deliberate lie whose one +consequence — the optimiser may treat the slot as undefined and drop the store that filled it — is +closed by writing the value through a `*volatile u32` first, a language-level guarantee rather than +a hope about an optimiser. Verified on the REAL module, not on a scratch probe: at ReleaseSafe the +install path emits `movl $0, -0x4(%rbp)` / `stmxcsr -0x4(%rbp)` / `andl` / `movl %eax, -0x8(%rbp)` / +`ldmxcsr -0x8(%rbp)` — read, mask, volatile store, write back, value and not address, other bits +preserved. At Debug the same shape against `(%rsp)`. + +Whole-graph cross-builds now clean at `{Debug, ReleaseSafe}` for `x86_64-linux-gnu` and +`x86_64-windows-gnu`. **`aarch64-linux-gnu` cannot be cross-built from this machine at all**, and +that is NOT caused by this branch: `main` at `5259344` fails identically with +`std/atomic.zig:21:20: unable to evaluate comptime expression`, measured through a worktree. The +ARM64 leg is therefore CI-only, which is one more thing the `ubuntu-24.04-arm` cell of Gate D buys. + + +**2026-08-14 11:40 — Gate A GO. The two enumerations owed with the first push of Gate B, and one +of them shows my earlier count was wrong.** + +**(a) The eighteen uncounted tests, measured differentially against `main` at `5259344` through a +worktree, same host, same `-Dcpu=baseline`:** `main` reports 264 steps / 1700 of 1717 / **17 +skipped**; this branch reports 266 / 1728 of 1746 / **18 skipped**. Delta: **exactly one new skip**, +and it is `test "float_env: x86_64 keeps DAZ and FTZ representable apart"`, guarded by +`builtin.cpu.arch != .x86_64` and observed by name in the standalone run (`6/8 … SKIP`). So the +answer to the question that mattered is yes: the x86_64 arm IS among the skipped on this host, +which is the sound outcome — Rosetta neutralises `MXCSR` here, and a test that cannot discriminate +must not pretend to. The other seventeen are `main`'s pre-existing baseline (platform and render +cases gated on Linux, Windows or a Vulkan device), untouched by this branch. The test arithmetic +closes independently: `1717 + 29 = 1746`, and 29 is exactly `trig` 8 + `float_env` 8 + +`determinism` 4 + `asm_inventory` 9. `forge_3d` 530 → 531 is the `determinism.zig` split alone. + +**(b) There is no fourth verbatim citation. There are FOUR LINES and THREE citations**, the C1.1 +metric having spanned two lines — a count that conflated lines with citations, which is the very +class an enumeration exists to catch. Enumerated: `Conséquences` (`ci.yml`), `Sources de vérité` +(`float_env.zig`), and the C1.1 metric sentence (`float_env.zig`). Under the language rule the +first two are IDENTIFIERS and stay; the third was a SENTENCE and is now rendered in English with a +file + block pointer, keeping the STRUCTURE ("a whitelist of exactly two") and the LOCALISATION, +which are what protected the fix. Same correction in this journal. Remaining: three identifiers, +zero sentences. Noted on the instrument: the audit pattern OVER-reports — it flagged +`plus two negatives` on the English "plus" — which is the safe direction, but a raw count from it +can no longer be quoted without being classified. + +**2026-08-14 11:45 — a residual of the corpus correction itself, reported not patched.** The +re-uploaded `engine-invariants.md` fixes `ARCH-031` rule 5 exactly as intended, and +`engine-platform.md` §4 is complete — it names the file placement and enumerates the three sites. +**Superseded on 2026-08-16: §4 must carry NO count at all.** The enumeration is what went false — +P1-3 derived ten sites from source, three of them inside modules — and a corpus section that lists +sites will drift again the next time a thread is born. Guy's ruling: §4 states the RULE, the set is +derived and journalled. The patch travels with the milestone's others. +But `ARCH-031`'s **Conséquences** block, one screen below the fix in the same entry, still reads +still says, in substance, that installing the float state belongs to the job system's +thread creation and not to each module: it names a TIER as owner, which rule 5 was corrected to stop +doing, and it omits the process-entry half that the two `main` sites cover. (Rendered rather than +quoted, by session 1's own rule: an identifier stays verbatim, a SENTENCE is rendered in English +with a pointer — here `ARCH-031`, block *Conséquences*.) Class §5.5, "a correction upstream leaves its earlier version +downstream until swept". KB files are not this milestone's to edit. + + +**2026-08-14 12:10 — Gate B, the named STOP condition: it lives in the TEST SCAFFOLD, so it is +fixed here.** The retained set is `World.active` in `tests/solver_test.zig`; `computePairs` appended +to it and `sortDedup` deduplicated, and NOTHING ever removed — `removeBody` walks it to apply the W4 +wake but takes no entry out. The engine holds no retained set at all: `Broadphase.computePairs` is +moved-driven and returns a DELTA, so the persistent set belongs to its caller, which is the harness +today and `PhysicsWorld` at M1.1.15. There is therefore no engine retention to change and the +"STOP and return" clause does not apply. It also matches what `CLAUDE.md` has recorded since +M1.1.6 — "the test harness keeps every emitted pair — a conservative superset" — so the harness was +BEHIND the normative rule (§1.7 step 2, "removal on fat-AABB separation only"), never ahead of it. + +The fix needs exactly one thing from the engine, and it is data rather than policy: `Bvh.proxyAabb` +was already public and documented "the stored fat AABB", but the multi-layer `Broadphase` did not +forward it. Two read-only accessors added — `proxyAabb` and `unboundedShape` — the same class as +`BodyManager.entity()` at M1.1.10, a column that existed from day one and had never been reachable. +The RULE stays in the caller deliberately: pair retention IS the wake graph (§1.8.7), so putting it +in the acceleration structure would move an islands-and-sleep behaviour into the broadphase. + +Three cases in the predicate, exhaustive on what a proxy can be: two boxes overlap; a box against a +half-space uses `Aabb.overlapsHalfSpace`, the SAME formula the traversal uses and not a second copy; +two half-spaces are both static and can never separate, so they are retained unconditionally. The +FAT boxes are compared, which is what gives the rule its hysteresis. + +**Measured, and the prediction I refused to assume held.** 531/531 still green with pruning live, so +no existing behaviour moved — including `test "small hop within the fat margin keeps the contact +pair alive"`, the M1.1.6 Codex P1 pin, which passes because a sub-margin hop does not separate the +FAT boxes. The new probe brings it to 532/532 at `{Debug, ReleaseSafe} × {f32, f64}`. + +**And it discriminates, which "green" alone would not show.** Counter-factual on the OBJECT — the +prune neutralised in place — fails EXACTLY ONE test, the new one, on exactly the assertion that +carries the claim (`world.active.items.len < retained_in_contact`), with `compilation errors` = 0 so +it is a measurement. The other 531 stay green under it, which is the second half of the same result: +pruning changed no existing behaviour. + +The probe is written as the COMPLEMENT of the small-hop pin, and the pair is the point: one asserts +the set retains inside the margin, the other that it prunes outside it. Either alone is satisfied by +a degenerate rule — never prune, or always prune — and only the two together pin §1.7 step 2. It +also carries its own positive witness (the pair is asserted to EXIST before it is shown to +disappear), without which "the set shrank" would be satisfied by a set that was empty throughout. + + +**2026-08-14 12:40 — Gate B, the canonical scenario. Built, and its SCOPE measured before any +witness exists.** `tests/determinism/scenario.zig` carries the seven frozen elements, body creation +order numbered in the code because that order fixes `BodyId`, hence the island rank, hence the +resolution order — reordering it is a different scenario and invalidates every witness. Sleeping is +ON (the transitions are one of the four traces; `initNoSleep` is for CONVERGENCE measurements and +would silence it). Elements are separated along X so only the intended interactions occur; the +half-space is infinite and underlies all of them, which is the point of having one. + +**The scope test is the deliverable, not the build.** A witness taken over a scene where the groups +never meet, the sensor never fires or the mesh never yields a second constraint would be perfectly +stable and would prove nothing — the trace would agree with itself because nothing happened. So one +test drives 400 frames and asserts each of the seven mechanisms is OBSERVED: a contact against the +half-space; a sleep transition; the island count both rising and falling (a merge-only scene would +pass a weaker test); two constraints sharing one `pair_key`, which is the third term of the ordering +key actually being needed; exactly one sensor `entered` and one `exited`; the slider still above +4.9 m/s so it remains the residual carrier; and the character resolving ground rather than sinking. + +**It failed twice, and both failures were in my own probe or my own arithmetic — neither in the +scene.** First, `saw_ground_contact` was false because I had assumed the ground carries `BodyId` 0: +a `BodyId` is a GENERATIONAL handle (`index:24 | generation:8`), not a slot number, and `pair_key` +is `min << 32 | max` so which half holds the ground is not mine to assume either. Fixed by STORING +the two static handles and testing both halves against them. Second, the sensor reported two +`entered` instead of one; hypothesis — the trigger box at `y = 2` spans `[0, 4]` and its lowest face +touches the half-space boundary exactly, so it detects the GROUND as a second permanent pair — +tested by lifting it to `y = 3`, and the count went to one. Confirmed by measurement rather than by +argument. A third, smaller: my comment counted eleven mobile bodies where the constructor makes +twelve, and the count assertion caught the comment rather than the code; the arithmetic is now +written out term by term so a reader can check it against the constructor. + +535/535 at `{Debug, ReleaseSafe} × {f32, f64}`. + + +**2026-08-14 13:10 — Gate B, the instrument stands.** `trace.zig` (the three artifact kinds), +`run.zig` (the replayable entry) and `determinism_main.zig` (the shell), plus +`zig build forge-determinism`. First real output, `f32` / Debug: +`self-reproducible : OK (1000 frames, 32000 B chain, 24000 B discrete, 20160 B poses)`, +`divergence frame : none within K=60`. The sizes are checkable by hand, which is the point: +1000 × 32 B of SHA-256; 12 mobile bodies × 7 scalars × 4 B = 336 B per pose frame × 60 = 20160. +At `-Dphysics_f64=true` the poses come to 40320 B — exactly double — which is an independent check +that the encoding follows `Real` rather than a hard-coded width. + +Four decisions, each recorded because a later reader would otherwise have to guess: + +- **SHA-256, not a fast hash.** A witness has to survive a compiler patch bump: a standardised + digest is defined by its specification, a non-cryptographic one by an implementation free to + change under us. The chain defends against an unannounced change of algorithm, not an adversary. +- **Chained, not a digest of the concatenation.** A chain LOCATES: compared link by link it names + the first differing frame, and "the outputs differ" is not a diagnosis. +- **The deviation metric REUSES the sleep criterion** (§1.8.3), `‖Δx‖ + 2·r·‖vec(Δq)‖`, trig-free, + with `r` the `sleep_radius` the engine already computes at body creation. Two formulas for one + geometric fact is the defect class this repository names; no second one is invented and no + constant is fabricated. The relative rotation goes through the shared `Quat` operations for the + same reason — a hand-expanded Hamilton product here would be a second copy of a formula + `foundation/math` owns. +- **`determinism_main.zig` sits at `forge_3d/` level, not beside the harness**, and that is a + MEASURED constraint rather than a preference: a Zig module's import path is rooted at its root + source file's directory, so an executable rooted inside `tests/determinism/` cannot reach + `config.zig` or `root.zig` at all — `error: import of file outside module path` on every upward + import. The harness proper stays where the brief puts it. + +Four liveness guards ride with it, none asked for by the brief and all of the same family as the +inventory's call-site counter: the artifacts' SIZES are asserted before their equality (two empty +artifacts are byte-identical too); adjacent chain links are asserted DISTINCT (a chain that stopped +advancing would compare equal to itself for ever); the discrete window is asserted NOT constant (a +window where nothing happened would have parity across two ISAs and prove nothing); and the +deviation metric is asserted to fire at frame 0 against a reference displaced by one metre, which +is what proves it is wired to the reference at all rather than answering from the run alone. + +539/539 at `{Debug, ReleaseSafe} × {f32, f64}`; full suite 1736/1754, the same 18 skips as before. + + +**2026-08-14 13:40 — Gate B GO. Point 3: the threshold form CONFIRMED per-body, and PINNED, +because correct-but-unpinned is one refactor from wrong.** Read from source rather than from +memory: `r` is `sleepRadius(id)`, read INSIDE the per-body loop, and the comparison is +`deviation > divergence_factor * r` with that same body's `r`. So the form C1.1 requires was +already there. What was missing is that nothing could catch its loss: the canonical scenario's +twelve mobile bodies are all of comparable size, so an ABSOLUTE threshold passes over it — the +"assertion valid only through a tacit property of its fixture" class, and undetectable once a +witness is committed over the scene that hides it. + +The predicate is therefore extracted as `bodyExceeds(translation, rotation_chord, r)` and pinned by +two tests an absolute form cannot pass. The first takes two bodies three orders of magnitude apart +— 1 cm and 10 m — and gives each half then twice ITS OWN threshold: a per-body form answers +false-then-true for both, while any constant necessarily misclassifies one of the two columns, and +the test states that arithmetic explicitly (the large body's half-threshold displacement is above +the small body's whole threshold). The second pins the ROTATION weighting: at zero translation the +`2·r` numerator and the `1e-4·r` denominator cancel, so the chord criterion must be +radius-independent — dropping `r` from either side would leave the translation tests green while +moving the rotation criterion by three orders of magnitude between the two bodies. + +**Counter-factual on the object:** the per-body threshold replaced in place by an absolute one +fails EXACTLY those two tests and nothing else — 539 of 541 still green, `compilation errors` = 0, +so it is a measurement and the extraction changed no behaviour. + +**2026-08-14 13:45 — Point 4: naming the one engine-side defect of the milestone, and measuring its +blast radius.** `'*m'` is an inline-assembly OPERAND CONSTRAINT, not a symbol or a value. It was +the constraint on the two `MXCSR` accessors — `[out] "*m" (&word)` for `stmxcsr`, `[in] "*m" +(&local)` for `ldmxcsr` — declaring "this operand is a pointer to memory the assembly will access". +It is valid under LLVM, which is what ReleaseSafe and ReleaseFast use, and REJECTED OUTRIGHT by +Zig 0.16's self-hosted x86 backend, which is what Debug uses: `error: invalid constraint: '*m'`. +Hence a green ReleaseSafe leg and a red `ubuntu-24.04 / Debug` leg, and nothing else red. + +Fixed by `"=m"` as an OUTPUT on both instructions — the only spelling both backends accept and give +the same meaning to — plus a `*volatile u32` store on the write path, because `"=m"` tells the +optimiser the assembly DEFINES that slot and would otherwise license dropping the store that filled +it. + +**Blast radius, measured and not asserted:** `git show --name-only bba33f4` returns ONE file, +`src/foundation/math/float_env.zig`, +41/−3 — a file this milestone created. Zero contact with the +solver, warm start, islands, sleep, sensors, the controller algorithm or query semantics. Its only +`forge_3d` consumer is the read-only check in `determinism.zig`. **The Gate A out-of-scope list is +untouched and Gate A does not reopen.** + + +**2026-08-14 15:10 — Gate C unblocked by arbitration, and the backend question measured rather than +inferred.** The blocker's mechanism is replaced: the standalone `workflow_dispatch` file is DELETED +— an untriggerable workflow left in the tree is itself a statement measured false — and generation +becomes a `witness-generation` JOB in `ci.yml`, which is already on the default branch and +therefore already runs on the PR head. It is gated on a `Witness-regen: ` trailer in the +head commit message: stronger provenance than a dispatch input, because the declaration is reviewed +in the diff, greppable and permanent, where a form field leaves no trace in the repository. Absent +the trailer the job skips. Verified NOT to be in `ci-gate`'s `needs` — a conditional job in a +required dependency makes the gate skip or fail by configuration. Both workflow files re-parsed +after the edit (6 jobs in `ci.yml`, 1 in `nightly-fuzz.yml`). The trailer gate itself is exercised +in BOTH directions on four bodies: it fires on a real trailer, and stays silent with no trailer, +with the string appearing mid-line, and with an empty trailer. + +**WHICH BACKEND SERVES WHICH CORNER — measured on all eight, by `@compileError(@tagName( +builtin.zig_backend))` per (target, mode), because the review asked not to be taken on faith:** + +| target | Debug | ReleaseSafe | +|---|---|---| +| `x86_64-linux-gnu` | **`stage2_x86_64`** | `stage2_llvm` | +| `x86_64-windows-gnu` | `stage2_llvm` | `stage2_llvm` | +| `aarch64-linux-gnu` | `stage2_llvm` | `stage2_llvm` | +| `aarch64-macos` | `stage2_llvm` | `stage2_llvm` | + +**Exactly ONE corner of the eight uses the self-hosted backend: `x86_64-linux` in Debug.** The +review's inference was right in direction and one notch wide in scope — it said "Debug x86_64", and +Windows x86_64 Debug is LLVM. That refinement is not cosmetic, it is the whole reason the `'*m'` +failure appeared on `ubuntu-24.04 / Debug` and on no other cell. + +Three consequences, in the order they bite. **(a)** The "Debug ≡ ReleaseSafe on aarch64-macOS" +indication compares LLVM to LLVM and is therefore worth even less for x86_64 than the fourth- +platform caveat already made it: it is not the same comparison at all. **(b)** The cross-mode +agreement risk is concentrated on ONE cell — `x86_64-linux`, which is exactly the cell the witness +generation runs on, so the check has teeth precisely where it is armed. **(c)** The mode-keying of +the continuous chain, decided on the weak ground that the equality was a hypothesis, is justified +by a strong one nobody knew when it was written. + +**RECLASSIFICATION accepted: the cross-mode check MEASURES and REPORTS, it does not fail the job.** +C1.1 level 1 holds "à ISA, **build**, configuration et nombre de workers identiques" — Debug and +ReleaseSafe are two BUILDS, so a disagreement between them does not breach level 1 and must not be +dressed as a level-1 failure. What it would breach is the two frozen keys carried by precision +alone, which is a design question and a STOP, not something a CI job may decide. The workflow now +emits `::warning::` with that reasoning in it, and the job continues. + +**The reference window's mode is NAMED, not implicit: ReleaseSafe.** Both mode-independent kinds +are taken from the ReleaseSafe run and the choice is written into `PROVENANCE.txt` alongside the +run URL, the cell, the CPU pinning and the cross-mode result. Without naming it a later +regeneration could switch mode in silence; and if the two modes ever disagree, an ARM64 Debug cell +would fold an ISA difference and a BACKEND difference into one number — which the measurement above +makes a live possibility rather than a theoretical one. + +**`nightly-fuzz.yml` corrected.** Its claim that `workflow_dispatch` could be triggered from a +branch before merge is measured false and had been dormant since M0.7. The correction states what +was measured and why the file's own dispatch works today (it is already on `main`). + + +**2026-08-14 17:35 — the trailer gate ran, skipped in green, and had never read the commit it +claimed to read. Two defects, one root cause, and the second would have SHIPPED.** On a +`pull_request` event `actions/checkout` defaults to `refs/pull/N/merge`, a SYNTHETIC merge commit +whose message is `Merge into `. So `git log -1 --pretty=%B` read text GitHub generates, +never the text the author wrote: **the gate could not fire at all**, and it failed the most +expensive way available — silently, by skipping, on a job reporting success. + +The second consequence is worse and would have survived every green run: `github.sha` on that event +is the SAME merge commit, an ephemeral object outside the branch history that no later reader can +resolve. `PROVENANCE.txt` — the file whose entire purpose is provenance, and the whole argument for +preferring a commit trailer to a dispatch input — would have recorded a sha nobody can check out. +The mechanism was chosen for traceability and was quietly writing an untraceable reference. + +Fixed by pinning `ref: ${{ github.event.pull_request.head.sha }}` on the checkout and recording that +sha, which repairs both and is the right tree to generate from anyway: a witness pins the REVIEWED +commit, not a merge preview of it that ceases to exist. + +**And the failure mode itself is closed, not just the instance.** "No trailer" and "wrong commit" +were the same observable, which is exactly why the skip looked legitimate. The step now asserts +`git rev-parse HEAD` against the PR head sha and FAILS on mismatch, so only a genuine absence can +skip. Exercised in six directions, two of them new: the guard fires on a wrong checkout even WITH a +real trailer present — the precise case that was skipping — and a synthetic merge message skips. + +**THIS IS THE M1.1.11.1 LESSON, VERBATIM AND SELF-INFLICTED: audit the WIRING, not the result.** The +four-direction test run before the push was not wrong, it was aimed one level too low — it proved +the `sed` discriminated correctly over four bodies HANDED to it, and never asked where CI would get +the body. A probe that validates a parser while assuming its input source is a probe that measures +nothing about the input source. Defect count for the milestone: **nine, of which eight are in the +measuring apparatus or a comment and one in the engine** — and this one, like the `'*m'`, was found +by running it in the real environment rather than by reasoning about it locally. + + +**2026-08-14 18:10 — BLOCKER B2. The cross-mode measurement fired, and the cause is an `ARCH-031` +rule 3 violation at the single most-used float operation in the engine. STOP, per the Gate C +arbitration: the check measures and reports, a divergence is a STOP and a return.** + +Run: `https://github.com/weldengine/weld/actions/runs/31826507195`, commit `80de9d9`, +`ubuntu-24.04`, `-Dcpu=baseline`, Zig 0.16.0. Second run `31826925052` (commit `16b3b88`) retains +both sides. + +**What was measured.** + +| witness | Debug vs ReleaseSafe | +|---|---| +| `discrete-f32.bin` | identical | +| `discrete-f64.bin` | identical | +| `reference-window-f64.bin` | identical, byte for byte | +| `continuous-chain-f64` | identical over all 1000 frames | +| `reference-window-f32.bin` | **DIFFERS — first at frame 1, 38 of 84 scalars** | +| `continuous-chain-f32` | **DIFFERS from frame 1, never re-converges** | + +Frame 1 is the FIRST stepped frame, so this is not accumulated chaos: it is an arithmetic +difference inside the first tick. It then amplifies — by frame 38 of the 60-frame window, body 3's +`y` reads `3.4778921604156494` against `3.4804224967956543`, a gap of **2.53e-3 m**, 10613 ULP. +That is physically visible, not noise. + +**THE CAUSE, ESTABLISHED STATICALLY AND NOT INFERRED FROM THE DIVERGENCE.** `Vec.dot` +(`vec.zig:77`) and `Vec.lengthSq` (`:94`) are `@reduce(.Add, ...)`, and the summation order of +`@reduce` is **backend-defined — the language does not specify it**. Disassembled at +`-mcpu=baseline`, `x86_64-linux`, 3-lane f32: + +- **LLVM**, at BOTH `-O Debug` and `-O ReleaseSafe`: `acc = p0`, `shufps $0x55` → `+p1`, + `unpckhpd`/`movhlps` → `+p2`. That is **`(p0 + p1) + p2`**. +- **Self-hosted `stage2_x86_64`**: `movhlps` extracts `p2`, `addss` adds `p0`, `shufps $0x1` + extracts `p1`, `addss`. That is **`p1 + (p2 + p0)`**. + +Float addition is not associative, and the two orders disagree on **313816 of 1000000** random f32 +triples in `[-50, 50]` — **31.4%**, by 1 ULP; first witness `(-12.127813, 21.12078, -40.04462)` +giving `0xC1F869C9` against `0xC1F869C8`. + +**And the f64 agreement is explained by the same measurement rather than assumed:** at f64 BOTH +backends produce `(p0 + p1) + p2` — LLVM by `mulpd`+`unpckhpd`, the self-hosted backend by a +sequential `addsd` loop over memory. Same order, same bits, 1000 frames. The asymmetry the CI +reported is therefore fully accounted for, mechanism included. + +**THIS REFUTES A GATE A FINDING I SIGNED OFF**, and the refutation is the more important half. +Recon recorded that the x86_64 reduce is "the same left fold as the x86_64 scalar chain", and built +a criterion on it: *the marker falls because the sequence is ordered*. Both halves are wrong. The +listing read at Gate A was **one backend's**, generalised to the target; and ORDERED IS NOT THE +PROPERTY — `p1 + (p2 + p0)` is perfectly ordered, is not the total reduction the criterion was +watching for, and breaks rule 3 exactly as a `haddps` would. **The property is the SAME order across +every backend, which `@reduce` does not give and cannot be asked to give.** The line is struck +through in place with its refutation, not silently replaced. + +**Why this is a STOP and not a fix.** Scope names two behavioural changes as "the complete list"; +this would be a third, in `foundation/math/`, on `dot` and `lengthSq` — the hottest path in the +engine, read by every consumer of `foundation/math`, not only by `forge_3d`. Three things follow +that are Guy's to decide and not mine: + +1. **Level 1 as WORDED is not breached.** C1.1 holds bit-exactness "à ISA, **build**, configuration + et nombre de workers identiques", and Debug and ReleaseSafe are two builds. So a literal reading + says nothing failed. But what the divergence EXPOSES is that bit-exactness currently rests on + *which backend the toolchain happens to select*, so any Zig version changing `@reduce` lowering + silently invalidates every committed witness. That is the risk `ARCH-031` rule 3 exists to remove. +2. **The four discrete invariants are stable at both precisions** — the actual subject of C1.1 + level 2 never moved. The divergence is numeric, never decisional. That is worth weighing before + deciding how much this costs. +3. **The witness set is NOT committed.** Both mode-independent kinds are keyed by precision alone, + which ASSERTS mode-independence; the measurement refutes that assertion at f32. Committing a file + whose name states what the measurement has just denied is the silently re-baselined witness under + another name. + +**Defect count: eleven, of which nine are in the measuring apparatus or a comment and TWO in the +engine** (`'*m'`, and this). Both engine defects were found by executing in the real environment, +never by reasoning about it locally — and this one additionally required disassembling both +backends, because the divergence alone says *that* they differ and never *why*. + + +**2026-08-15 — B2 CLOSED by arbitration: the correction lands in this milestone, at the class and +not the list, with a durable guard.** The arbitration named the motive, and it is not that a +criterion is in default: level 1 as worded holds. It is the deferral rule — every witness committed +before the correction is a witness to regenerate after it, and M1.1.25 and M1.A both replay this +harness. And the Scope list of two was not exceeded but WRONG: its two items are not a quota of +behavioural changes, they are two pinnings of an arithmetic the platform was choosing for us, and +`@reduce` is the same act, left out on the strength of a recon measurement that proved partial. +Third item named, not exception granted. + +**THE CLASS, ENUMERATED RATHER THAN ASSUMED.** All 44 `@reduce` sites in `src/ tools/ bench/ tests/`, +classified by element type. Twenty-six are BOOLEAN (`.And`/`.Or` over comparison results) and two are +INTEGER — exact under any order, untouched. **Eighteen were float: twelve production and six in tests +and a bench.** The recon note's "exactly four sites" was right about `.Add` and blind to `.Min`/`.Max`, +which carry the same order question plus NaN propagation. Sweeping the list would have left fourteen. + +| kind | sites | +|---|---| +| `.Add` float | `vec.zig:77` (dot), `vec.zig:94` (lengthSq), `aabb.zig:109`, `mesh.zig:930` | +| `.Max` / `.Min` float | `aabb.zig:224`/`:225`, `vec.zig:156`, `vec.zig:169`, `body_manager.zig:2438`, `shapecast.zig:487` | +| float, tests + bench | `mesh_test.zig` ×5, `bench/forge_3d_mesh.zig` ×1 | + +New `src/foundation/math/reduce.zig` — `foldAdd` / `foldMul` / `foldMax` / `foldMin`, ascending lane +order, left-associated, float-only by `@compileError`. Five of the eighteen sites needed no helper at +all: they were `@reduce(.Max, @abs(v.data))`, which IS `Vec.maxAbsComponent`, so they became a call to +the method that already existed. + +**THE FACT ABOUT `.strict`, ESTABLISHED — AND IT INVERTS MY OWN BLOCKER REPORT.** B2 asserted that +`@reduce` "delegates the order to the backend BY CONSTRUCTION, and the language does not specify one". +**That is measured FALSE.** The langref's `@reduce` entry reads "performing a sequential horizontal +reduction of its elements", and: "when applied on floating point types the operation associativity is +preserved, unless the float mode is set to `Optimized`". The order IS specified. (Read at tag +`0.15.2` — `ziglang/zig` carries no public `0.16.0` tag, so the exact 0.16 wording is not citable and +this is the nearest tagged text; stated as a caveat rather than smoothed over.) + +So the finding is sharper, not weaker: **LLVM honours the specification and `stage2_x86_64` does +not.** `p₁ + (p₂ + p₀)` is neither sequential in lane order nor an associativity-preserving rendering +of the sequential fold, contradicting both halves of that sentence. Emitted identically under the +DEFAULT float mode and under an explicit `@setFloatMode(.strict)`, at `-O Debug`, `ReleaseSafe` and +`ReleaseFast` — six listings, instruction for instruction. **A Zig compiler defect, owed upstream.** +The minimal reproducer is written and self-verified at `briefs/artifacts/m1.1.14-zig-reduce-order-repro.zig`, +where `probe()` and `expected()` sit in ONE object compiled by ONE backend in ONE float mode and the +second emits the sequential chain the first does not — so the backend can do it, and does when the +source says so. **NOT FILED: an upstream report is an outward-facing act and belongs to Guy.** + +The correction ships regardless, and the arbitration's reason is the right one: bit-exactness must not +rest on a compiler fix, including one that arrives. Both file headers were corrected in place — a +superseded justification left standing beside its replacement is the motif this repository has swept +twice already. + +**THE FREE REGRESSION, DEMANDED AND MEASURED.** Under LLVM the emitted order was already +`(p₀ + p₁) + p₂`, so an explicit source fold must be BIT-IDENTICAL there. Measured locally rather than +argued, and on a broader target than CI will check: `aarch64-macOS` runs LLVM in BOTH modes, so all +four corners are LLVM corners. A worktree at `4e3551c` and the working tree generated the full witness +set at f32 and f64, Debug and ReleaseSafe — **12 files, 12 identical, 0 moved**, over 1000-frame chains +and 60-frame windows, with a control confirming `cmp` discriminates these files at all. Re-run on the +final tree after the lint rule and the doc corrections landed: 12 of 12 again. + +Confirmed at the ASSEMBLY level too, which is where the claim really lives: the x86_64 LLVM listing for +the fold is instruction-for-instruction the listing for `@reduce`. On AArch64 LLVM the fold still +reaches `faddp` — `fmul v0.4s`, `faddp s1, v0.2s`, `mov s0, v0.s[2]`, `fadd` — a pair instruction +REALISING the source fold, which is exactly what the rewritten rule 3 admits. And the self-hosted +x86_64 backend now emits `acc = v₀`, `addss v₁`, `addss v₂`: the divergent corner is fixed, proven +statically before CI ever ran. + +**THE GUARD.** `tools/weld_lint/rules/no_float_reduce.zig` fails `zig build lint` on any `@reduce` +with `.Add`/`.Mul`/`.Min`/`.Max` — the four operations meaningful on floats. `.And`/`.Or`/`.Xor` are +never flagged. The escape is a per-SITE `WELD_INTEGER_LANES` marker rather than a path allowlist, +because a path allowlist grants the exemption to a whole file including the float reduction added to +it next year, and because the linter is a tokenizer that cannot see an element type — the marker is +the claim, in the reader's view, at the one place it applies. + +**THE GUARD'S OWN TESTS DID NOT RUN, AND THAT TOOK THREE MEASUREMENTS TO ESTABLISH.** `tools/weld_lint` +had no test target at all: every `test` block under it was dead text, compiled by nothing. Rooting +`addTest` at `main.zig` ran nothing — the rules arrive through plain `const` imports, which a test +build does not analyse. **Rooting at a new file that `pub const`-re-exports them ran nothing either**: +`zig test` reported "All 0 tests passed" over nine re-exported files, one holding eight test blocks — +which REFUTES the standing note that a `pub const @import` collects tests, at Zig 0.16. What works is +`comptime { _ = @import(…); }`, and each step was settled by appending a deliberately failing test and +watching for red, never by reading the wiring and believing it. + +Then the tests ran, and **one of mine failed** — the per-site scoping probe. Its premise was wrong, +not the rule: with two adjacent statements the marker's line IS the line above the second. But the +failure exposed a real leak, so the rule was tightened — the line above exempts only when it is a PURE +COMMENT line — and a trailing marker no longer speaks for the statement beneath it. Two layers now +exist and neither substitutes for the other: the inline tests prove the rule's LOGIC and cannot see +whether it is wired into `runLint`; the `tests/lint/bad/float_reduce/` corpus runs the real binary and +proves the WIRING while reading nothing but an exit code. + +Counter-factuals, all run: reintroducing one real float `@reduce` in `vec.zig` fires the rule and +removing it clears; the three bad fixtures exit 1 and the good one 0; the two-site fixture yields +exactly 2 diagnostics. + +**Local verification.** `zig build lint` clean. `zig build test` 268/268 steps, 1754/1772 (18 skipped). +`test-forge-3d` 541/541 at all four corners — Debug and ReleaseSafe × f32 and f64. + +**Tooling, self-reported.** `echo " $(basename $f) rc=$?"` reports `basename`'s status, not the +command's — the substitution runs first and clears `$?`. It printed rc=0 for three fixtures that had +just printed rc=1 under direct invocation, and only re-running with the code captured FIRST settled it. +Same family as the `zig build … | tail && next` fact already recorded, and the reason the tree was +re-checked instead of the reading being believed. + + +**2026-08-15 — BLOCKER B3, the dead-test sweep, and it was right to come before any witness.** +`pub const @import` does not collect tests at Zig 0.16 — established on `tools/weld_lint`, and +nothing said that module was the only one wired that way. Measured across the tree: **1871 `test` +blocks in source against 1772 collected by the suite, a delta of 99.** + +**THE LANGUAGE FACT, SETTLED BY A NEUTRAL FOUR-CASE EXPERIMENT** rather than by either codebase, +because two in-repo sources contradicted each other on it — `src/etch/root.zig` carries a paragraph +stating the correct rule and, twenty lines below, a note stating its opposite ("a public re-export +of the root module is force-analyzed, tests included"). Two files, `leaf.zig` with two tests and a +root importing it four ways: + +| root form | tests collected from `leaf.zig` | +|---|---| +| `pub const leaf = @import("leaf.zig");` alone | **0** | +| `const leaf = …; comptime { _ = leaf; }` | **2** | +| `pub const leaf = …;` + the root has a test of its own | **0** | +| `const leaf = …;` unreferenced | **0** | + +The note's OBSERVATION was sound — removing the line left the count unchanged — because `types.zig` +is also reached through `interp.zig`, which is pinned and uses its declarations. Attributing that to +the re-export turned a true measurement into a false general rule, and **the rule is what a later +reader would have acted on**: the note called the line "not load-bearing", and removing it on that +authority would have darkened 152 test blocks in silence. Corrected in place. + +**THE INVENTORY**, every line established by counter-factual or by reconciling per-target counts: + +| area | dead blocks | cause | +|---|---|---| +| `src/modules/render/` | 45 | no test target at all | +| `src/etch/zig_codegen/` | 37 | reached only by `pub const`, the form that does not analyse | +| `tools/bindgen/` | 8 | no test target | +| `src/foundation/math/exact.zig` | 2 | referenced only through two of its DECLS | +| `src/modules/audio/dummy.zig` | 1 | no test target | +| `src/core/ipc/shm_posix.zig` | 1 | selected at comptime inside `shm.zig` | + +**`zig_codegen/root.zig` already carried the CORRECT guard for its own three test files.** What was +broken was the link to it. The right mechanism existed one level down and the level above used the +form that never reaches it. + +**REPAIRED AND RUNNING: +12 tests, 1772 → 1784, all green.** `exact.zig` (2) — the exact integer +arithmetic M1.1.11.1 spent eleven rounds establishing, unguarded until today; `shm_posix.zig` (1); +`src/modules/audio` (1) and `tools/bindgen` (8), each given a test target. Bindgen needed a +`tests.zig` root: the target rooted at `main.zig` moved the suite total by **zero**, the same trap a +third time, caught only because the number was checked instead of the target being trusted. Its +`emitter.zig` test then failed to compile — `ArrayList.writer` was removed at 0.16 — and was migrated +to `Io.Writer.Allocating`, the in-tree idiom. + +**TWO AREAS HELD, WITH THEIR MOTIVE IN `build.zig` AND IN `src/etch/root.zig`, because what they +uncover is bigger than the dead tests.** + +**`src/etch/zig_codegen/` — PRODUCTION CODE THAT DOES NOT COMPILE UNDER THE PINNED TOOLCHAIN.** Once +wired, eight compilation errors; five were test rot and were repaired (`Io.Dir.realpathAlloc` gone, +`SourceSpan` private in `ast.zig` and public in its owner `token.zig`, `CodegenError` private at the +codegen root and public in `errors.zig`). The residue is in `cache.zig` itself: `std.fs.cwd()` was +removed at 0.16, so `writeHash`, `readCachedHash` and `root.writeFileAndCache` have been dead code +since the pin — and `root.zig` already documents `cookTree` as having "no current in-tree consumer". +Repair is not a rename: the 0.16 filesystem API takes an `io` parameter these functions do not have, +so it changes the codegen cache's public signatures. An Etch decision, not a determinism one. + +**`src/modules/render/` — A CONFIRMED USE-AFTER-RETURN IN PRODUCTION, LIVE SINCE M0.4.** Wiring its +45 tests turns two red, and a probe settled why rather than leaving it to inference: `buildPass` +returns a `Pass` whose `writes` slice points at an anonymous literal in **its own stack frame**. +Measured on `depth_prepass` — `writes.ptr` is a stack address, `depth_attachment` reads `false` +immediately after the call, and a FRESH call at the SAME address reads `true`. `forward.zig` fails +identically. The `len` assertion passes and the content assertion fails, which is the signature. The +fix is an ownership decision in the render graph. + +**THE FLOOR RE-STATED ON THE NEW DENOMINATOR.** The milestone's inherited floor of 527 forge tests +was measured on a partial denominator — not wrong for `forge_3d`, whose target reconciles exactly +(541 collected against 541 source blocks), but stated in a repository where 94 blocks elsewhere never +ran. The current suite floor is **1784 collected, 1766 passing, 18 skipped**, with 82 blocks named +and held above. Any future comparison against a pre-M1.1.14 total is comparing two different +denominators. + +**METHOD, and it is the same rule twice.** Every step was settled by BREAKING something and watching +for red, never by reading the wiring and believing it — and three times the reading would have been +wrong. This is the counter-face of the rule already recorded: a probe must report the SIZE of what it +rendered its verdict over, and a verdict must carry the IDENTITY of its object. A test target that +collects nothing reports success; a poll on a stale job id reports yesterday's success; a trailer read +from a merge commit reports absence. All three answer, and all three answer green. + + +**2026-08-15 (continued) — the five unaccounted blocks, and render fixed on branch A.** + +**THE FIVE UNACCOUNTED BLOCKS: FOUR OF THEM WERE A UNIT ERROR IN MY OWN SUMMARY.** The inventory +published `render 45` — a COLLECTED count — while the 99 delta was computed against `render 49`, a +SOURCE count. The gap is `gal/vulkan/conv.zig`, four blocks, reached by neither measurement: on +macOS the GAL selects the Null backend, so the Vulkan bodies are never analysed. That is a +hypothesis with a named test rather than an excuse — the Linux and Windows cells should collect +them, and the CI totals will say so. Mixing a source count with a collected count in one table is +exactly the class this milestone keeps finding, applied to its own summary. + +**THE FIFTH IS NOT LOCATED, AND HERE IS ITS BOUND.** All 1871 matches are real declarations — every +one ends in `{`, and there are ZERO indented (nested-container) declarations tree-wide. The +aggregate 1829 is authoritative: summing the per-step totals gives 1874, and the 45 difference is +the failing render step, which the summary lists TWICE (verified by grep). So 42 blocks are dead, +41 are named, and one is not. Every area of `src/` and `tools/` reconciles exactly per file, and all +three `simd/tests/` files were probed COLLECTED, so it lies inside `tests/`. Two attempts to +localise it failed and are recorded rather than hidden: a full per-binary enumeration exceeded a +ten-minute budget, and an attempt to pair the ordered `test_specs` list against the ordered summary +totals produced 79 mismatches — the summary tree does not follow declaration order, so that pairing +was INVALID and its output was discarded rather than reported. Bounded, resumable, and one block. + +**RENDER — measured first, then branched, exactly as instructed.** Wiring the target collects **45 +tests, ZERO compilation errors, exactly 2 failures** — the two already known. Nothing new fell, and +that had to be measured rather than assumed, since an uncompiled module can hide anything. + +**BRANCH A, and the criterion is met without argument.** The storage is bounded in place and no +ownership question arises: `Config` is ALREADY the pass's `ctx`, so it had to outlive the `Pass` by +construction. A fixed `[1]ResourceUsage` per slice, filled by `buildPass`, costs one field and one +`*const Config` → `*Config`. The only call sites in the tree are the three tests themselves — the +passes have no production consumer yet — so the change is local and the defect was latent rather +than live. `capture.zig` carried the SAME defect with a test that never checked content, and is +fixed in the same pass; finding it needed reading the other two files, not trusting that two +failures meant two defects. + +The fix is pinned STRUCTURALLY and not only by the restored assertions: the tests now assert that +`reads.ptr` and `writes.ptr` point INTO the config. Counter-factual run — restoring the stack +literal in `depth_prepass` turns the suite red again. + +**Suite: 274/274 steps, 1811/1829 tests, 18 skipped, zero failures.** Render's 45 blocks are now +live; the two areas still held are `zig_codegen` (37, production that does not compile under the +pinned toolchain) and `conv.zig` (4, platform-conditional). + +**WHAT A DEAD TEST ACTUALLY COSTS, and it is not 99 sleeping assertions.** An uncollected `test` +block is never ANALYSED, so the code it instantiates gets no elaboration and no type-checking. That +single mechanism explains both held areas: `zig_codegen/cache.zig` stopped compiling when +`std.fs.cwd()` was removed at 0.16 and nobody saw it, and render's use-after-return survived ten +milestones. **A dead test switches off COMPILATION coverage, not merely assertion coverage** — which +is what justifies the sweep's cost, and what the corpus note must say, or the lesson will be +remembered as "some assertions were asleep". + +**AND A CLASS WORTH MORE THAN ITS INSTANCE: an annotation that ASSERTS HARMLESSNESS.** "not +load-bearing" is a claim about the whole program written from inside one file, and acting on it +would have darkened 152 blocks. Same shape as a Gate A conclusion drawn from one backend's listing. +Signature: "not load-bearing", "no consumer", "safe to remove" are claims of MEASUREMENT; without +the measurement attached they read as unverified and justify no deletion. + +**THE FLOOR, RE-STATED PRECISELY.** `forge_3d` reconciles EXACTLY — 541 collected against 541 source +blocks — so this milestone's own oracle was never partial; what was partial was the repository's +denominator. Suite floor is now **1829 collected, 1811 passing, 18 skipped**. Any comparison against +a pre-M1.1.14 total compares two different denominators. + + +**2026-08-15 (continued) — MY OWN DEFECT, from the sweep, and the gap in local verification it +exposes.** The dead-test wire-in of `shm_posix.zig` was UNCONDITIONAL. That file opens with a +`@compileError` for any OS but Linux and macOS, so the wire-in broke every Windows build — and +`ubuntu-24.04 / Debug` with them, because the failing step is `forge-asm-inventory`, which +CROSS-COMPILES to `x86_64-windows-gnu` from any host. Three cells red on one line of mine. + +**And `zig build test` on a POSIX host could not have shown it.** The CI cell runs five steps; the +local suite runs one. `forge-asm-inventory`, `bindgen-verify`, `verify-synth-100` and `test-etch` +are not part of `zig build test`, and two of them cross-compile. **A green local suite is a smaller +claim than a green cell**, and reporting one as the other is the same unit confusion as comparing a +collected count to a source count. All five now run locally before a push. + +Fixed by MIRRORING `shm.zig`'s own comptime dispatch rather than inventing a second condition. +Two further slips inside that fix, both caught by the tools rather than by re-reading: the +`builtin` import first landed inside a `comptime` block instead of container scope, and then +BETWEEN a doc comment and its declaration, which `doc_comments` flagged. The lint rule earned its +place twice in one edit. + + +**2026-08-15 (continued) — the matrix fully green, and the mechanical guard delivered INACTIVE.** + +Run `31892846238` on `9389d0d`: **all ten jobs green**, both Windows cells included — so the +`file_hash FileNotFound` failure did not recur, which is one more measurement on the environmental +side of that question. + +**THE FREE MEASUREMENT WAS NOT AVAILABLE AND NOW IS.** The per-cell totals could not be read: the CI +step runs `zig build test` WITHOUT `--summary all`, so no cell ever printed a total. Added. The +4-versus-5 question resolves on the next run, and the platform-dependent floor becomes readable +permanently rather than by a one-off. + +**THE MECHANICAL GUARD — built, its own fixtures green, and DELIBERATELY NOT WIRED.** +`tools/weld_lint/dead_tests.zig` computes the closure statically, builds nothing and runs nothing, +derives its roots from `build.zig` instead of duplicating them, declares its exclusions with their +owning milestone, and reports the SIZE of what it judged. Sixteen tests including the four hostile +fixtures the review required: alive at depth three, dead when bound-but-unreferenced, dead on an +inline field access, alive on a comptime guard, plus exclusion handling and `..` resolution. **One +of them caught a defect in the guard itself** — a both-ends `trim` had eaten the trailing space of +`"_ = "`, so the comptime-guard fixture reported its pinned file dead. Which is what they are for. + +**THE FIRST RUN AGAINST THE REAL TREE TESTED THE CRITERION AND THE CRITERION SURVIVED.** +`src/modules/render/root.zig` binds everything with bare `pub const` and its 45 tests ARE collected +— which read as a flat refutation until the file was opened: it carries a +`comptime { _ = gal; _ = render_graph.pass; … }` guard, so those names ARE referenced. Bare +`pub const` still collects nothing, and the two observations that looked contradictory are one rule. +**That was three minutes from being written up as a contradiction between two build-system +measurements.** + +What the run DID refute is the implementation, in two named places, and both are recorded in the +tool's own header: it discovers 18 roots and misses every target built by the `test_specs` LOOP, +whose root path is a loop variable rather than a literal, so all of `tests/` reports dead; and the +inline-field-access rule is too strong — `pub const Graph = @import("graph.zig").Graph;` DOES pull +that file's tests when `Graph` is later referenced, while `triangleIsFlat` pulled nothing because +nothing referenced it. **The discriminator is the REFERENCE, everywhere, not the syntax of the +import**, and the rule must fold into the reference test instead of sitting before it. + +Both defects push toward false DEAD — the direction chosen deliberately, since a missed edge is +noisy and visible while an invented edge says green. Wiring it now would make `zig build lint` +report 116 files it cannot justify, and **a guard nobody can believe is worse than none.** + + +**2026-08-15 (continued) — the Windows cache key READ, and the guard from 116 down to 3.** + +**WINDOWS: READ, NOT RE-RUN, AND IT YIELDS A MECHANISM.** The key carried os, mode and Zig version +but **NOT the CPU axis this milestone introduced**, and both restore-keys fall back on it. So every +`-Dcpu=baseline` build could restore a cache saved from a NATIVE-CPU build. Zig's manifests are +cpu-aware, so that never returns a wrong object — it returns a MISS and rebuilds, leaving BOTH +variants of everything in one `.zig-cache`. That bloat is what meets the 2 GB save cap already +recorded in `ci.yml` (`was 6214569092; purged contents before save`), **and the purge is the +corruption**: it deletes objects while the manifests referencing them survive into the archive, so +the next restore hands `zig build` a manifest whose entry cannot be stat'd — `file_hash +FileNotFound` on an executable the build believes it has. + +Every measured property follows without further assumption: **`ReleaseSafe` ONLY**, because the +cache steps are gated on that mode and Debug has none; **`windows-2025` only**, where the purge was +observed; **content varying at CONSTANT COMMIT**, because which entries were purged depends on the +save; and **one commit green then red a day apart**, because it depends on which generation was +restored. `ZIG_CPU` now joins all ten key lines, which fixes the rule 6 pinning AND makes every +pre-M1.1.14 archive unrestorable — those being the mixed-CPU ones that feed the purge. + +**THE GUARD: 116 FILES / 405 BLOCKS → 3 FILES / 9 BLOCKS, and the three are FALSE DEADS**, verified +by probe. No false ALIVE has been observed at any point. Both defects the first run exposed are +fixed with a hostile fixture each, as required: loop-built roots (`test_specs`) and bare +string-array roots (the IPC list) are discovered, and the inline-field-access rule FOLDED INTO the +reference test. + +**AND CORRECTING THAT RULE TOOK DOWN ONE OF MY OWN FIXTURES, which is the finding.** The fixture +named "an inline field access is not an edge" bound `f` and then wrote `pub const g = f;` — it +REFERENCED the name, so it encoded the refuted rule and passed only because the implementation +shared the mistake. A fixture agreeing with the code it tests proves nothing about either. It is +corrected to the real `exact.zig` shape (bound, never referenced) and paired with its sibling, the +same syntax WITH the reference, which must be ALIVE. + +Corroboration worth recording: before the last root form landed, the closure's `live_tests` read +**1829** against a suite total of **1829**. + + +**2026-08-15 (continued) — the purge made all-or-nothing, and the guard's remaining defect NAMED.** + +**THE KEY WAS THE TRIGGER; THE PURGE IS THE MECHANISM, and only the first was fixed.** Pinning +`ZIG_CPU` removed what was inflating the archive here, but the cap will be met again for reasons +that have nothing to do with it — the Gate E work adds the f64 axis and the ARM cell, so more +targets and bigger archives, on the cell that already touched 6.2 GB against 2. So the SAVE is now +all-or-nothing: the size is measured first, and a save that would be partial is SKIPPED loudly. A +skipped save means the next run is cold — slower, and correct. **A cache that lies costs more than +no cache**, and this milestone spent six runs and two wrong hypotheses learning it. + +Expected and not to be re-read as a regression: `ZIG_CPU` makes the whole cache history +unrestorable, so the next runs are cold by construction. + +**THE GUARD'S REMAINING DEFECT IS ONE MECHANISM FOR ALL THREE FALSE DEADS, and it is named.** The +reference search is scoped to the BINDING FILE and must be scoped to the CLOSURE. `gal/root.zig` +binds `pub const barriers` and never touches it — but `render_graph/pass.zig` writes +`gal.barriers.Access`, and THAT reference is what makes the file live. Same shape for +`comptime_query`, bound in `ecs/root.zig` and referenced from `core/root.zig`. `transport_posix.zig` +is the third: an `@import` inside a `switch` assigned to a referenced `const`, which the head parser +does not read as a binding at all. + +**The criterion is untouched — the reference is the discriminant — and the fix is a FIXPOINT**: a +binding goes live once its name is referenced anywhere already in the closure, which admits files, +which admit references. Two hypotheses were eliminated on the way and are recorded so nobody retries +them: it is NOT "the root file is special" — a `pub const` left unreferenced ONE LEVEL DOWN collects +nothing either, measured on a three-file fixture — and it is not the syntax of the import. + +**And the control that authorises activation is named too, because it is not the fixture count**: +`live_tests` equalling the suite's own collected total, two unrelated computations landing on one +number. It read 1829 against 1829 at an intermediate state; it must be redone on the final one, and +that is the gate. + + +**2026-08-15 (continued) — THE BILATERAL CONTROL FIRED ON ITS FIRST RUN, and that is the result.** + +Restricting the fixpoint to already-live files was the right discriminant and it closed all three +false deads: the guard reports `clean`. **And `live_tests` reads 1884 against a suite total of +1829** — a count ABOVE the total, which is exactly the signature of a guard that admits too much. +`src/etch/zig_codegen/` is admitted although it does not compile, so its 37 blocks are counted live +and its declared exclusion never fires. + +**Twenty-one passing fixtures did not see this. One number did.** Too permissive overshoots, too +strict undershoots, and only equality excludes both — which is why the control, and not the fixture +count, is the gate. It proved that on its first application. + +Two further things this stretch produced. A **segfault on Zig's `0xaa` poison** at the guard's first +real run: admissions were appended to the queue while iterating `queue.items`, so the realloc left +the loop variable dangling — staged and appended after the scan. And **fixture 7's expectation was +mine and wrong**: it asserted two dead files where only one holds `test` blocks, the report counting +dead tests' homes rather than every unreached file. + +**The remaining work is named**: the cross-closure reference must not admit a file through a binding +whose name is referenced only in a file itself reachable ONLY through that same binding — the +mutual-reference shape `zig_codegen` has. And the expected equality is PLATFORM-DEPENDENT, which the +intermediate 1829 masked: the guard is static and blind to comptime dispatch, so **macOS must expect +`live_tests = total + 4`** for `gal/vulkan/conv.zig`, and **Linux exact equality**. A predicted and +explained gap is a result; the same gap unannounced reads as a broken guard and nobody activates it. + + +**2026-08-16 — SESSION 2, closed-list items 1 to 5. The control closes to the digit, and getting +there cost FIVE defects in the guard, every one of them in the direction that says green.** + +**ITEM 1 — the decomposition, RE-MEASURED rather than read from this journal.** Opening state: +closure 1886 blocks over 133 roots, suite `Build Summary` 1826/1844 collected. Delta **42**. The +per-step attribution is exact and needed no pairing by order — the pairing that was invalid at +session 1: `src/etch/root.zig` closure 538 against a step total of **501** (−37), `render/root.zig` +49 against **45** (−4), and every other module root equal digit for digit (`forge_3d` 541/541, +`core` 167/167, `foundation` 62/62, `asset_pipeline` 54/54, `forge/api` 15/15). 37 + 4 = 41, so the +residual was **1**, and a multiset difference of the 130 per-root counts against the 129 step totals +named it without a search: `tests/ecs/no_alloc_steady_state_stress.zig`, one block, hanging off +`zig build test-stress` and kept out of `zig build test` by a `build.zig` comment that says so in +those words. **THE FIFTH BLOCK WAS NEVER DEAD AND NEVER MISSING** — it is in a step the suite does +not run, which is why two attempts to localise it inside `tests/` failed. + +The same difference exposed **three INVENTED roots**: `wayland_protocols/{core,xdg_shell, +xdg_decoration}.zig` are the ARGUMENTS OF A `zig fmt` `addSystemCommand`, and the bare-array matcher +took any line that is a quoted `.zig` path plus a comma. They carry no `test` block so they moved no +count, and `in_closure` stayed at 382 when they went — measured, so they masked nothing. The +mechanism is the one the file's own header forbids. + +**ITEM 4 — the control instrument, and the unit error it was built to remove.** `analyze` carried +ONE `seen` set across every root, so a file two targets reach was counted ONCE while the suite +compiles and runs it TWICE. Set cardinality against multiset cardinality: the same +collected-versus-source confusion this milestone has now made three times, here inside its own +instrument. `weld_lint dead-tests --per-root` computes each root's closure separately and sums with +multiplicity. **Measured, and the expected inflation did not occur: the per-root sum equals the +global sum, because module-name imports are not followed, so the closures are disjoint in practice.** +The concern was real and its magnitude is zero — which is a measurement, not an assumption. + +**FIVE DEFECTS, all false-ALIVE, and the last two are the milestone's own recurring classes.** + +1. **Invented roots** (above). Root discovery is re-anchored on the WIRING: a table is read only + when a `for` loop over it calls `addTest`. One rule replaces two shape-matchers and covers both + table forms without naming either. +2. **`modulePath` borrowed the NEXT declaration's literal.** It searched forward under a + 400-character WINDOW; when a module's `root_source_file` is not a literal — `b.path(spec.path)`, + the ordinary loop shape — it ran on and took whatever came next. Bounded to the declaration's own + initializer by brace matching. **Latent, not firing on today's `build.zig`** (the loop's module is + followed by enough `addImport` lines to push the next literal past 400 bytes), and found by a + fixture written for defect 1 — which is the argument for a boundary rather than a window. +3. **A commented-out import read as an import.** `src/etch/root.zig` shows the guard that WOULD wire + the subtree, `// _ = @import("zig_codegen/root.zig");`, and that line's head ends in `_ =`. The + reference search had been taught to skip comments earlier in this milestone, for the same file and + nearly the same sentence; the IMPORT site had not. A correction applied at one site and not at its + twin. +4. **A BINDING COUNTING AS A REFERENCE TO ITSELF — the self-referential rule item 3 forbids, and it + was already in the code rather than merely proposed.** Found by a temporary probe printing the + admitting mechanism, after three hypotheses had been eliminated by reading: `PROBE admit + zig_codegen/root.zig via CROSS-FILE name=codegen_zig from=src/etch/root.zig` — the cross-file loop + scanning the binding file itself. Two causes in one line. The same-file test was POINTER IDENTITY + and never fired, because the production reader allocates a fresh buffer per call; and the scan + passes `osrc.len` as the binding-line start, a sentinel meaning "nothing to skip", so with the + binding file as its own candidate the exclusion window was EMPTY and `pub const codegen_zig = + @import(…)` was its own justification. Thirty-seven blocks. Fixed by comparing PATHS, which is + correct whatever the reader does with memory. + **No fixture could have seen it**: `Fixture.read` returned the map's own stable pointer, so + pointer identity worked there and only there — a harness differing from production in the exact + property under test agrees with the code instead of judging it. It now allocates per call, and the + case is pinned. +5. **A cross-MODULE reference licensing an intra-module edge.** With defect 4 closed, the admission + moved to `tests/etch/keyword_ident_test.zig`, which names `codegen_zig` but reaches it through the + `weld_etch` MODULE — and Zig collects no tests across a module boundary. **ITEM 2's second edge, + and the per-step measurement named it exactly as instructed, never a hand search.** + +**ITEM 3 — the closure is now PER ROOT, monotone, with no rule by which two files vouch for each +other.** Not the refined mutual-reference rule the session-1 note proposed: that rule is refused, and +the self-referential one that existed is removed rather than narrowed. Per root, `src/etch/root.zig` +yields **501** — its step's collected total, exactly — and no root's own closure holds `zig_codegen`, +so the DECLARED EXCLUSION FIRES for the first time: 7 files, 37 blocks, reported and not failed on. +That silence was the symptom session 1 recorded. + +**ITEM 5 — the guard is ACTIVE**, on the existing `lint` gate plus its own `zig build dead-tests` +step. Counter-factual on the OBJECT: an orphan file under `src/foundation/math/` makes `zig build +lint` exit 1 and names the file and its block count; removing it restores green. `zig fmt --check` +green. The `doc_comments` rule fired on the new public declaration and earned its place again. + +**THE BILATERAL CONTROL, on the final state, macOS/aarch64: closure 1857 − 5 declared uncollected += 1852, against `Build Summary: 274/274 steps succeeded; 1834/1852 tests passed (18 skipped)`. +EXACT.** The five are declared IN ADVANCE in a new `uncollected` table with the mechanism of each — +`conv.zig` 4 blocks, macOS only, the Null backend never analysing the Vulkan bodies; and the stress +test's 1 block, every platform. Linux's declared gap is therefore 1, not 5. `--expect-collected=N` +verifies it and exits 1 on a mismatch, with the diagnosis written in both directions; the +counter-factual at 1853 fires. + +**Files touched, four, each accounted for.** `tools/weld_lint/dead_tests.zig` and +`tools/weld_lint/main.zig` — the guard, created by this milestone at session 1 and carried by +Recorded deviation 3, so this is the same file continuing rather than a new surface; +`build.zig`, which the brief's own list carries; and this brief. Nothing under `src/` moved, and the +suite total changing from 1844 to 1852 is entirely the eight fixtures added here. + +**Tooling, self-reported.** `live_tests` rising by exactly the number of fixtures I had just added +was NOT evidence that the binary had been rebuilt — the count reads SOURCE files, so it moves whether +or not anything recompiled. I used it as rebuild evidence for one round and had to settle the +question by looking for a string of the edit inside the binary. Same family as the facts already in +`engine-development-workflow.md` §4.8: an artefact interrogated after the fact does not see what +happened outside its field. + +**2026-08-16 — ITEM 6 EXERCISED, AND THE CI RUN CORRECTED MY OWN DECLARED TABLE ON ITS FIRST +APPLICATION.** Run `https://github.com/weldengine/weld/actions/runs/31932309771`, commit `479e507`: +**all ten jobs green**, both Windows cells included. + +**The all-or-nothing save FIRED, on two cells and not one.** `ubuntu-24.04 / ReleaseSafe` measured +3 094 724 608 bytes and `windows-2025 / ReleaseSafe` 2 595 335 168, both over the 2 147 483 648 cap, +and both SKIPPED the save with the `::warning::` naming the mechanism. Both cells are green, which is +the whole point: a skipped save costs a cold run and preserves correctness, where the partial save it +replaces corrupted the archive. The cap is now met on LINUX as well as Windows — the milestone's own +f64 axis and ARM cell will only add targets — so cold runs are the expected regime and not a +regression. + +**The trailer gate was verified in the NEGATIVE direction on a real run**, which is the direction +that failed silently before: the log reads `--- head commit 479e5072… ---` then `--- no +Witness-regen trailer; skipping generation ---`, and the sha it compared is the PR HEAD, not the +synthetic merge commit. Read from the job log rather than inferred from the job's green tick. + +**AND THE FREE MEASUREMENT PAID, AGAINST ME.** `--summary all`, added at session 1 and never yet +read, gives per-cell totals: `ubuntu-24.04` **1840/1852** (12 skipped, both modes), `windows-2025` +**1818/1850** (32 skipped, both modes), against macOS **1834/1852** (18 skipped). So Linux collects +1852 — NOT the 1856 my declared table predicted a few hours earlier. + +Attributed by the same method as before, and it lands exactly. **`conv.zig` is collected on NO +platform**: the render step reports 45 against a closure of 49 on macOS, on Linux and on Windows +alike. The session-1 hypothesis — the macOS Null backend is the cause, so Linux and Windows will +collect those four — is **REFUTED BY MEASUREMENT**, and I had written it into the table this morning +as `only_on = .macos` on the strength of that inherited note rather than of a measurement. It is now +declared for every platform, with the ABSENCE stated as established and the mechanism inside Zig's +lazy analysis explicitly NOT guessed at. + +The Linux-versus-Windows difference is **one step out of 129** — `core`, 167 against 165 — and the +two blocks are `src/core/ipc/shm_posix.zig` and `src/core/ipc/transport_posix.zig`, comptime-selected +inside `shm.zig` and `transport.zig` and never analysed on Windows. Declared, Windows-only. + +**The control now reconciles on all three platforms, closure 1857 throughout:** macOS 1857 − 5 = +1852 against 1852; Linux 1857 − 5 = 1852 against 1852; Windows 1857 − 7 = 1850 against 1850. Three +independent computations against three independent suites, exact in every case — a far stronger +statement than the single macOS agreement that authorised activation, and it exists only because the +gap was declared in advance and therefore had to be defended when the numbers arrived. + +**2026-08-16 — ITEMS 7 AND 8. B2 IS CLOSED BY MEASUREMENT, ON THE ONE CELL WHERE IT COULD BE.** +Run `https://github.com/weldengine/weld/actions/runs/31933218714`, commit `662cf07`, `ubuntu-24.04`, +`-Dcpu=baseline`, `zig version` reporting `0.16.0`. + +**ITEM 7 — `cross_mode_agreement=true`.** All three witness kinds, at both precisions, byte for byte +between Debug and ReleaseSafe. That is the exact comparison that FAILED at session 1 and produced +blocker B2: `x86_64-linux` is the only one of the eight (target, mode) corners whose Debug goes +through Zig's self-hosted backend while its ReleaseSafe goes through LLVM, so it is the only place in +the matrix where two independent instruction selections meet — and it is where the float `@reduce` +order divergence showed. After the explicit left fold at 18 sites, they agree. + +**Corroborated INDEPENDENTLY of the job's own `cmp`**, which matters because a check and the thing it +checks should not be the same artifact: the committed `SHA256SUMS.txt` shows +`continuous-chain-f32-Debug.bin` and `continuous-chain-f32-ReleaseSafe.bin` sharing one digest, and +the same for the f64 pair. The agreement is written into the record, not only into a transient step. + +**ITEM 8 — the set is committed, and it is EIGHT files, not six.** Six are pass/fail — four +`continuous-chain--` plus two `discrete-` — and the two +`reference-window-` are measurement inputs by the frozen keys' own wording, carried +because Gate D's ARM64 cell reads them to compute its divergence frame. Stating the breakdown rather +than picking a reading. `PROVENANCE.txt` carries the run URL, the cell, the CPU pinning, the PR HEAD +sha, the cross-mode result, and — per file — name, generator mode, compiler and run URL. Checksums +re-verified against the committed copies after the copy, with a one-byte counter-factual proving the +check discriminates. + +**AND THE WITNESSES HAD NO READER, which the entry point's own doc comment denied.** It said +"run, compare against committed witnesses" while the code below it did nothing of the kind — a text +asserting more than its code does, the class this repository treats as a defect of code. Corrected by +making the CODE true rather than the text weaker: `tests/determinism/witness.zig` embeds the +committed files and compares them. + +The comparison PARSES the discrete stream rather than carrying frame offsets beside it. The format is +length-prefixed at every level precisely so a reader never infers a boundary from content, and the +400 bytes per frame this scenario happens to produce is a property of the SCENE — islands, +constraints and the retained set all vary — so reading it as a stride would work on this witness and +silently mis-locate every mismatch on the next. + +**Asymmetry by contract, not by convenience.** The chain is compared on x86_64 ONLY: asserting it on +AArch64 would be asserting level 3, which C1.1 places out of Phase 1, so a failure there would be a +true negative dressed as a defect. The skip is PRINTED. The four discrete traces are compared +everywhere, ISA included, because they derive from integers. + +**MEASURED HERE, aarch64 macOS against x86_64 Linux witnesses:** chain SKIPPED, **all four traces +OK**, and the continuous **divergence frame `none within K=60`** — the poses stay inside +`1e-4 × body scale` of the x86_64 window for the whole window. A strong INDICATION for Gate D on a +FOURTH platform, and not the measurement: C1.1 names `ubuntu-24.04-arm`, whose libc and ABI differ. + +Six tests, each with its counter-factual on the OBJECT: a chain witness with one byte flipped at +frame 412 is reported AT frame 412 and the intact pair still agrees; a discrete witness corrupted in +frame 7's THIRD section is reported at frame 7 AND named `per-pair manifold cardinality`, the probe +placed by the parser under test rather than by an offset guessed from a hex dump; and the parser's +own non-vacuity — sixty frames must consume the committed file EXACTLY, or every mismatch it locates +after a mis-sized section names the wrong frame. `forge_3d` 541 → 547, one skipped on this host, +green at `{Debug, ReleaseSafe} × {f32, f64}`. + +**Tooling, self-reported.** My four-corner loop exited 1 while every corner reported `rc=0`: the last +statement of the loop body is `[ $rc -ne 0 ] && grep …`, which evaluates false — and therefore 1 — +exactly when nothing is wrong. The per-corner codes captured immediately after each build are the +reliable signal. Same family as the facts in `engine-development-workflow.md` §4.8. + +**2026-08-16 — THE WITNESSES HAD NO CONSUMER IN CI EITHER, and a grep says so: +`forge-determinism` appeared at exactly ONE line of `ci.yml`, inside the generation job's +`--write-witness` invocation. No matrix cell ran it.** So the committed set was inert on the very +platforms it exists to hold to account, and the brief's CI criterion — "`zig build +forge-determinism` is on the normal CI path, not behind a flag" — was unmet. Wired onto every cell, +unconditionally, and the workflow re-parsed to confirm it carries no `if:`. + +**Both precisions run ON the cell rather than by doubling the matrix.** `engine-platform.md` §8 +describes a `{os} × {mode} × {f32, f64}` matrix and that remains the shape; but the harness is a +two-minute step where a whole cell is not, so carrying the precision axis inside the step buys the +`{f32, f64} × {Debug, ReleaseSafe}` coverage Gate C's exit names, at a fraction of eight full cells. +The cheap half of the axis, and the half the witnesses need. The full axis and the `ubuntu-24.04-arm` +cell stay Gate D/E work, named in Scope and not smuggled in here. + +Four local corners green before the push, `{Debug, ReleaseSafe} × {f32, f64}`: chain SKIPPED with its +reason printed, four traces OK, divergence frame none within K=60 in every one. + +### 2026-08-16 — GATE C, MEASURED AND CLOSED + +Run `https://github.com/weldengine/weld/actions/runs/31933791326`, commit `3fc21e5`, all nine jobs +green. The verdict is not the green tick: it is the eight lines below, read from the cell logs. + +**LEVEL 1 IS GREEN ON ITS NAMED EXIT.** `chain verdict: OK (1000 frames)` on every one of the eight +(cell × precision) combinations — `ubuntu-24.04` and `windows-2025`, `Debug` and `ReleaseSafe`, `f32` +and `f64`. The witnesses were produced on `ubuntu-24.04`; Windows reproducing them bit for bit on all +four `(precision, mode)` keys IS "chain identical between Linux and Windows", and the witness form +makes it stronger than a pairwise comparison would: the value is pinned in TIME, not merely between +two OSes. All four discrete traces `OK` on all eight as well. + +**THE FIVE, NAMED — and the expectation they were measured against was wrong in BOTH halves.** + +| file | blocks | uncollected on | +|---|---|---| +| `src/modules/render/gal/vulkan/conv.zig` | 4 | every platform | +| `tests/ecs/no_alloc_steady_state_stress.zig` | 1 | every platform | +| `src/core/ipc/shm_posix.zig` | 1 | windows only | +| `src/core/ipc/transport_posix.zig` | 1 | windows only | + +The prediction was `total + 4` for `conv.zig` on macOS, with a fifth block that would then have to be +platform-conditional too and thus located for free. Neither holds. `conv.zig` is collected on NO +platform — the render step reads 45 against a closure of 49 on macOS, Linux and Windows alike — and +the fifth is not platform-conditional at all: it is the M0.2.1 stress test, which hangs off +`zig build test-stress` and is kept out of `zig build test` by a `build.zig` comment that says so. +So the answer to the either/or is the second branch: it is something else, and now it is known. + +**Reconciled on all three platforms at closure 1863**, this run's own per-cell totals: +macOS `1863 − 5 = 1858` against 1858 · Linux `1863 − 5 = 1858` against 1858 · +Windows `1863 − 7 = 1856` against 1856. + +**The secondary control corroborates and is worth its line.** Skips went 18 → 19 on macOS while +Linux stayed at 12: the six new tests all run on x86_64, and exactly one of them — the chain +comparison — skips on AArch64. The number moved by the amount the printed skip predicted. + +**`none within K=60`, RECORDED AS A RESULT AND BOUNDED IN THE SAME BREATH.** It is measured on +aarch64 macOS against the x86_64 witnesses, and it is a real result: after the libm removal, the +absence of contraction, `.strict`, the explicit folds and a pinned FPU, few sources of inter-ISA +divergence remain, so this is plausible as well as observed. **It does NOT establish level 3**, and +the four bounds are why: + +1. it bounds a CONTINUOUS deviation at `1e-4 × body radius` — four orders coarser than an ULP — and + says nothing whatever about bit-equality; +2. it covers **60** frames, against the chain's 1000; +3. it is measured on THIS canonical scene, whose mobile bodies are of comparable size; +4. the cross-ISA instance is **aarch64 macOS**, a FOURTH platform with a different libc and ABI from + the `ubuntu-24.04-arm` cell C1.1 names. On the x86_64 cells the same line is a SELF-comparison + against a witness of their own ISA and carries no ISA information at all. + +Not vacuous, and that is already pinned: the harness's own fixture displaces the reference by one +metre and requires the metric to fire at frame 0. Without it, `none` and "the metric is dead" would +be the same observable. + +**ITEMS 2, 3 AND 5 — the three the previous report left silent.** + +**Item 2, the second edge into `zig_codegen`: CLOSED, and it was two edges rather than one.** The +temporary probe named the first mechanically — `via CROSS-FILE name=codegen_zig from=src/etch/root.zig`, +the binding file answering for itself. With that closed, the residual admission moved to +`tests/etch/keyword_ident_test.zig`, which names `codegen_zig` across the `weld_etch` MODULE +boundary; per-root scoping closed that one. No root's closure now holds the subtree, and its declared +exclusion FIRES — 7 files, 37 blocks — which is the silence session 1 recorded as the symptom. + +**Item 3, monotone growth from the `build.zig` roots: DONE, and the self-referential rule is REMOVED +rather than narrowed.** The rule session 1 proposed — refuse a binding whose name is referenced only +in a file reachable through that same binding — is refused as instructed. What was actually in the +code was worse and simpler: a binding vouching for ITSELF. Growth is now per root and monotone; files +only enter, a reference counts only from a file already admitted in the same root's closure, and no +two files vouch for each other. + +**Item 5, the guard: ACTIVE.** Wired into the `lint` step and given its own `zig build dead-tests`. +The counter-factual is on the object: an orphan file under `src/foundation/math/` makes +`zig build lint` exit 1 and names the file and its block count; removing it restores green. Its +out-of-closure list names the fifth block with its mechanism. The corpus sentence is therefore +payable: a dead test switches off compilation coverage, and something checks it. + +### 2026-08-16 — THE CACHE CAP, BEFORE THE MATRIX SHAPE + +**THE 2 GB CONSTANT HAS NO SOURCE ON THE CACHE IT GOVERNS, and that is measured rather than +suspected.** The line the whole diagnosis was built on — +`Zig cache exceeded 2147483648 bytes (was …); purged contents before save` — is emitted under +`Post Run weldengine/setup-zig@v0.1.0`. It is OUR OWN action, in its post step, speaking about the +cache IT manages; it is not `actions/cache`, and it is not `.zig-cache`. The literal `2147483648` +occurs exactly TWICE in all of `.github/`: in the comment quoting that message, and as the `limit=` +of our own pre-check. One cache's cap was transcribed onto another cache. + +The two are not even the same order of quantity. In bench run `31933791179` the setup-zig post step +reports **14 700 387 106 bytes on ubuntu** and **7 707 924 893 on windows** for the cache it purges, +against `.zig-cache` measuring **3 409 756 160** on `ubuntu-24.04 / ReleaseSafe` in CI run +`31933791326`. Local decomposition, for the "reduce what is cached" route: `.zig-cache/o` is ~99 % of +the tree, and it is the one directory that cannot be dropped — a manifest without its object IS the +corruption the skip exists to prevent. + +**So the raise is an EXPERIMENT and the log is its result**, framed with both outcomes written in +advance so neither can be read as a confirmation after the fact: `actions/cache/save` either saves a +3.4 GB archive — and the cells stop being permanently cold, which is what produces the warm +`windows-2025 / ReleaseSafe` figure the f64 axis decision needs and which does not exist today — or +it refuses with its own message, in which case refusing is SAFE and the constraint is finally the +platform's rather than a transcription. The outcome that would be a defect is a PURGE, whose +signature is already recorded; if it appears the raise is reverted. + +**AND THE WARNING THAT NAMES THE MECHANISM HAD ITS MECHANISM EATEN BY THE SHELL.** Backticks inside a +double-quoted `echo` are command substitution, so the log of run `31933791326` reads +`/home/runner/…sh: line 9: file_hash: command not found` and then +`##[warning]the next restore turns into . Cold is correct.` The one sentence whose entire job was to +name `file_hash FileNotFound` printed without it, on both cells, every run since the guard landed. +Found by reading the emitted log rather than the source — the same rule the rest of this milestone +was decided by. Single-quoted now. + +**THE EXPERIMENT ANSWERED: `actions/cache/save` SAVES.** Run +`https://github.com/weldengine/weld/actions/runs/31936486439`, all nine jobs green. +`windows-2025 / ReleaseSafe` measured 2 862 303 232 bytes and saved twice — post-build and final — +and `ubuntu-24.04 / ReleaseSafe` measured 3 803 447 296 and did the same. No purge from +`actions/cache`, no refusal, and no `file_hash FileNotFound` anywhere. + +**And it exposed a SECOND wrong quantity in the same check.** The archive `actions/cache` actually +stores is COMPRESSED: it reports `~656 MB (687 524 995 B)` on windows and `~757 MB (793 919 500 B)` +on ubuntu at restore. So the pre-check was comparing an uncompressed tree against a cap belonging to +another cache — wrong number AND wrong quantity, and the two errors happened to point the same way. + +**MY OWN FRAMING WAS WRONG TOO, AND THE TIMING REPORT SAYS SO.** "The cells are inert at every run" +is not what was happening: `cache_matched_key` on both ReleaseSafe cells resolves to +`…-d72a04453b…`, an archive from an earlier sha. The RESTORE worked throughout; only the SAVE was +skipped, so the archive never advanced past the last sha that managed to store one. Frozen, not +absent — and the difference matters, because it means the measured build times were already riding a +stale cache rather than starting from nothing. + +**The numbers, and they invert the concern.** Wall-clock per cell on that run: +`windows-2025 / ReleaseSafe` **4 min against a 55-min budget** (`build_seconds=17`, +`test_seconds=166`), `ubuntu-24.04 / ReleaseSafe` 4 min (82 s / 133 s), `ubuntu-24.04 / Debug` 3 min. +The tight cell is `windows-2025 / Debug` at **14 min against 20**, with `cache_enabled=false` by an +earlier deliberate decision and `build_seconds=281`, `test_seconds=521`. So the binding constraint on +any matrix growth is the Debug cell that has no cache by design, not the ReleaseSafe cells the cap +was starving. + +**What remains measured only after the NEXT run**: the first genuinely refreshed archive is the one +this run saved under `…-1cbc4370…`, so the fresh-cache figure for `windows-2025 / ReleaseSafe` — the +number that decides the f64 axis — comes from the run after it, not from this one. + +**NAMED, MEASURED, NOT TREATED — `setup-zig` STILL PURGES, on a quantity that overlaps ours.** Its +post step reports `was 2869161302` on windows and `was 4000990476` on ubuntu — 6.9 MB and 197 MB +above the `.zig-cache` figures measured in the same jobs, so what it caps is `.zig-cache` plus a +little, not the disjoint global cache the earlier comment assumed. Two mechanisms therefore cache +overlapping trees and one of them purges its copy to 2 GB before saving. Our restore is the one that +feeds `zig build` and it is intact, which is why the run is green; whether the purged copy can ever +win a restore is NOT established here and is not guessed at. It belongs to whoever next opens +`weldengine/setup-zig`, which is a different repository. + +**NAMED, MEASURED, NOT TREATED — a second cache-key finding, outside the closed list.** +`.github/workflows/bench.yml` still keys its cache +`zig-${os}-ReleaseSafe-${ZIG_VERSION}-${hashFiles}` — **no CPU axis** — while `ci.yml`'s ten key +lines gained one this milestone, and its own comment says it deliberately shares the scheme with +ci.yml's ReleaseSafe cells. It also builds with no `-Dcpu` at all, so it compiles for the runner's +native CPU. Pinning its key without pinning its build would record an axis it does not exercise, and +pinning its build would change the very numbers the benches exist to report — a consequence that is +not this milestone's to take. `ARCH-031` rule 6 is unmet there; measured, recorded, untreated. + +**~~NAMED, MEASURED, NOT TREATED — outside the closed list.~~ SUPERSEDED 2026-08-16: MISCLASSIFIED, +AND IT IS IN THIS MILESTONE.** The finding stands verbatim — `zig build lint` is invoked by **no +workflow**, a grep over all of `.github/workflows/` returns nothing, the only caller is the +`pre-commit` hook in `lefthook.yml`, and the brief's CI criterion "`zig build lint` green" has been +unverifiable on CI for this milestone's whole duration. What was wrong is where I sent it. + +The instruction it was filed under — anything that is not the discrete parity leaves as a plan entry +— was written about the ARM cell's fallout, and this is not that. **It voids the condition the +doctrine was granted on.** BOTH guards this milestone built hang off the `lint` step: the dead-test +closure and `no_float_reduce`. Neither has ever run in CI. So "the guard verifies it, with a +counter-factual on the object" was true of the LOCAL invocation and of nothing else, and the +milestone's own prohibition — writing as normative what nothing checks — was about to be committed by +the very entry claiming to have closed it. **Ninth instance of the shape, and this one is a verdict +rendered on something other than its object.** + +Treated here: `zig build lint` runs on `ubuntu-24.04 / Debug`, one cell, same arbitration as +`verify-synth-100` and `forge-asm-inventory` — it answers a property of the SOURCE, not of the host. +The counter-factual is at CELL level and not at invocation level, below. + +### 2026-08-15 — MEASURED FACTS, NEVER TO BE RE-DERIVED + +A resumed session that re-derives these will repeat this milestone's mistakes. Each line below +cost at least one round-trip. + +- **Float `@reduce` order.** LLVM lowers a 3-lane f32 sum as `(p₀+p₁)+p₂` at every optimisation + level; the self-hosted `stage2_x86_64` lowers it as `p₁+(p₂+p₀)`. The two disagree on **31.4 %** + of random f32 triples, by 1 ULP; at f64 both converge on `(p₀+p₁)+p₂`, which is why only the f32 + leg diverged. **Zig SPECIFIES the order** — "a sequential horizontal reduction", associativity + "preserved" outside `Optimized`, identical under an explicit `@setFloatMode(.strict)` at all + three levels. So this is a BACKEND BUG, not a language latitude. Upstream report written and NOT + filed, against **master**: `ziglang/zig` has no `0.16.0` tag and its newest release is `0.15.1`. +- **Two code generators on x86_64.** Debug uses `stage2_x86_64`, ReleaseSafe uses LLVM — measured + on all eight (target, mode) corners. On aarch64 it is LLVM in both. **Any local measurement on + aarch64-macOS is LLVM against LLVM** and says nothing about the self-hosted backend. +- **Test collection.** The discriminant is the **REFERENCE**, never the syntax of the import. + `comptime { _ = x; }` is not the rule, it is the idiom that forces the reference. Bare + `pub const` collects nothing; `pub const X = @import("f.zig").Decl;` collects `f.zig` once `X` is + referenced. A reference inside a COMMENT is not a reference. +- **Windows `file_hash FileNotFound`.** Cache-key fallback without a CPU axis → both CPU variants + in one `.zig-cache` → the 2 GB cap → a PARTIAL purge that deletes objects and keeps the manifests + referencing them → the next restore hands `zig build` an entry it cannot stat. `ZIG_CPU` added to + all ten key lines; the save is now all-or-nothing. Signature to recognise, should it return: + `ReleaseSafe` only, `windows-2025` only, content varying at CONSTANT commit. +- **A green local suite covers ONE step; a CI cell runs five, and two cross-compile.** Reporting + one as the other is the same unit error as comparing a collected count to a source count. +- **The floor is per PLATFORM, never absolute.** `forge_3d` reconciles exactly everywhere — 541 + against 541 — and it is THE oracle of this milestone. The repository total is not. +- **`src/etch/zig_codegen/` is NOT globally uncompilable, and the earlier wording was wrong.** + Two live test targets — `tests/etch/cook_consolidate_test.zig` and + `tests/etch/keyword_ident_test.zig` — reach `codegen_zig` in code through the `weld_etch` module + boundary, so the subtree IS elaborated. What does not compile is the subset the wire-in ADDS: + the three files under `zig_codegen/tests/` and `cache.zig`, reachable only by that path. The two + commit messages that flattened this stay as they are — a journal records a state at a date — and + this dated correction covers them. **The Gate E batch had not yet been produced**, so the false + sentence never entered the plan: the practical justification for writing doctrine on a complete + measurement rather than a first observation. + + +### 2026-08-16 — TWO MORE FROM THE SPEC BATCH, BOTH GUY'S, BOTH THE SAME FAMILY + +**FOURTEENTH INSTANCE — a verification check that searched for a string with misplaced emphasis and +returned FAILURE on a correct object.** The object was right; the probe's pattern carried bold +markers in the wrong position, so it matched nothing and reported the absence as a defect. It is the +mirror of the twelve before it — those rendered a verdict on the wrong object, this one rendered a +verdict on a correct object through a wrong pattern — and it lands in the same place: **a probe must +be tested against a case it is known to match before its silence is read as a finding.** The +milestone has now paid for both directions of the same lesson, three of the fourteen being Guy's own +and the rest mine. + +**AND THE SPEC BATCH VIOLATED THE RULE IT WAS WRITING.** `spec-changelog.md` described the +`engine-zig-conventions.md` correction by its OVER-ASSERTED first version — the formulation that had +already been narrowed and retracted. A correction applied upstream leaving its earlier version +standing downstream: the exact motif the batch was codifying as normative, committed by the batch +itself. Corrected in place. + +That the rule caught its own author, in the document stating it, on the same day, is the strongest +argument the milestone produced for it: it is not a discipline anyone sustains by attention. Only a +sweep of the class catches it, and only a mechanical guard catches it reliably — which is why the +dead-test guard is ACTIVE and why `zig build lint` now runs on a cell instead of in a hook alone. + +### 2026-08-16 — REVIEW ROUND: SIX FINDINGS, M1.1.14 DOES NOT CLOSE + +An external review of the instrument found six defects, **five of them the milestone's dominant +family** — an artefact rendering a verdict on something other than what it claims to measure, and +answering green. Instances thirteen to eighteen, in the milestone that documented the family and +shipped a guard against it; none of the six was covered by that guard, which is the measurement: +**the class survives its own doctrine wherever no mechanical guard covers it.** + +What the verdicts still buy, stated narrowly: level 1 holds — the chain compares real simulation +state on eight cells. Three of the four discrete invariants hold. The fourth has no non-vacuity +proof on the scenario where it is measured, so it does not. The deterministic cosine is not proven +bit-stable and is not exercised by the scenario. + +**P1-4 — REGENERATION WORKED ONLY WHEN IT WAS POINTLESS. Fixed first, and the order was imposed.** +Reproduced before the fix: with the committed witnesses correct, `--write-witness` exited 0; with +ONE byte of `discrete-f32.bin` altered — the only case where a regeneration means anything — it +wrote all three files and exited 1 on `error.WitnessMismatch`. Under `set -euo pipefail` the CI +generation step therefore died exactly in the case it exists for. + +The comparison no longer gates a GENERATION run; it is printed. Self-reproducibility still gates +unconditionally — a run that cannot repeat itself must never produce a witness, whatever the mode. +Verified in three directions, which is what proves the flag makes the run do two different things +rather than one disguised: generation on a stale witness `rc=0` + files written + mismatch named +with its frame and trace; verification on the same state `rc=1`; restored `rc=0`. + +**P1-3 — THE TEN SITES, and `ARCH-031` rule 5's enumeration was measured rather than inherited.** +The set is DERIVED from source: three thread-creation sites and seven process entry points in +`src/`. Three were covered. `src/core/platform/threading.zig:192` is NOT one of them — its `spawn` +sits inside a `test` block, established by reading the context and not the path, which is the same +verification the corpus's own enumeration had lacked. + +| site | kind | installed before | +|---|---|---| +| `core/jobs/scheduler.zig:199` — job-system workers | thread | yes | +| `runtime/main.zig:182` — IPC reader thread | thread | **no** | +| `render/shader_pipeline/hot_reload.zig:56` — shader watcher | thread | **no** | +| `runtime/main.zig:106` | entry | yes | +| `editor/main.zig:91` | entry | yes | +| `forge_3d/determinism_main.zig:48` | entry | **no** | +| `demo_etch_codegen.zig:16` | entry | **no** | +| `demo_etch_interp.zig:23` | entry | **no** | +| `foundation/simd/bench/paeth_bench.zig:11` | entry | **no** | +| `foundation/simd/bench/adler32_bench.zig:15` | entry | **no** | + +All ten now install, **with no exception for a program that compares nothing today**: such an +exception is a judgement, and a judgement does not survive — a program that compared nothing becomes +a fixture, and a float-kernel bench under an unpinned environment measures a configuration that +exists nowhere. The two SIMD kernels are integer today, which makes the cost nil and changes nothing +about the rule. + +**And the irony of the batch is that `determinism_main` was among the uncovered.** The determinism +instrument was the one entry point consuming the guarantee without installing it — it ASSERTED +through the module and the assertion passed because the inherited state happens to be the engine +default on Linux and Windows. Which is exactly why an assertion is a DETECTION mechanism and never a +substitute for installation. + +**THE BIT-NEUTRALITY CONTROL, and it needs no regeneration.** The comparison the twelve cells already +perform IS the control: witnesses unchanged, fresh output produced with the installation in place, so +green means bit-identical and means the inherited state equalled the installed one. **Its window +closes with P1-1**, which changes the canonical scenario and therefore every witness; once they are +regenerated they match by construction and the question becomes permanently unanswerable. So P1-4 and +P1-3 ship ALONE and the matrix verdict is read before P1-1 touches the scenario. + +Locally only the ISA-independent half is checkable and it holds: the four discrete traces match at +both precisions, chain correctly skipped on aarch64. + +### 2026-08-17 — THE BIT-NEUTRALITY VERDICT, and it is a deduction with an empirical corroboration + +Run 32019448000 on `2c29c7c`: **12 of 12 cells green**, `ci-gate` green. Chain `OK (1000 frames)` on +the 8 x86_64 cells, `SKIPPED` — printed — on the 4 aarch64 ones, 4 trace verdicts `OK` on all 12, +`divergence frame : none within K=60` on all 12. Byte counts track precision (20 160 pose bytes at +f32, 40 320 at f64, exactly double), so no cell took an empty path. + +**PROVENANCE, measured before reading a single verdict.** The 8 witnesses were added by `534bbdb` and +never touched since; `git diff 9e23dd9..2c29c7c -- witnesses/` is EMPTY. At `534bbdb`, +`determinism_main.zig` contained **zero** `float_env.install()`, and the harness is single-process +with no scheduler use whatsoever, so **no install site was on the witness-production path**. The +comparison is genuinely pre-install bytes against post-install run. `witness-generation` cannot have +contaminated it: separate job, separate runner, separate checkout, output to a detached path, and +every generation step gated on a `Witness-regen:` trailer this commit does not carry. + +**AND THE DEDUCTION IS STRONGER THAN THE BYTES, which is what verifying the bytes turned up.** + +1. `assertFloatEnvironment()` is `std.debug.assert(checkFloatEnvironment() == null)`, and + `std.debug.assert` is `if (!ok) unreachable` — checked illegal behaviour in Debug AND ReleaseSafe, + so **live in all 12 cells**, compiled out only in ReleaseFast. +2. The reader it consults is WITNESSED on both ISAs: per-field perturbation tests gated on + `controllable` (true for `.x86_64`, `.aarch64`), each moving the register and requiring the reader + to report the move. Non-vacuous by construction — the rounding field's ENCODING DIFFERS between + the two architectures (`01` is −∞ on x86_64 and +∞ on aarch64, visible in `installState`), so a + decode table copied from one to the other fails there. Collected via `math.zig`'s `_ = float_env`. +3. At `9e23dd9` — nothing installed on the witness path — that assertion PASSED on all 12 cells. + Therefore **the inherited state equalled `engine_default` on all 12, aarch64 included.** +4. `installState(engine_default)` writes `engine_default` into exactly the three fields it owns and + preserves every other bit verbatim. Installing `engine_default` into a state already equal to it + is a bit-level no-op. +5. **⇒ bit-neutrality follows DEDUCTIVELY on all 12 cells**, and the byte comparison corroborates it + end to end, at byte grain, over 1000 frames, on the 8 cells where level 1 applies. + +This is the relationship worth having: a deduction whose premises are each measured, plus an +independent empirical check. Either alone would be weaker — the deduction because premise 3 is a +CI observation, the bytes because on aarch64 the chain is skipped and the remaining artifacts +(integer traces, a tolerance window) do not discriminate a float-environment change. + +**A FRAMING THE REVIEW REFUTED BEFORE IT REACHED THIS FILE.** I was about to record that +bit-neutrality was *predictable from the source's documented ABI default* — that `0x1F80` decodes to +RC=00/FTZ=0/DAZ=0. FALSE: `float_env.zig` states only that the ABI leaves **the six exception masks** +at `0x1F80`, as an aside about why Rosetta's `stmxcsr → 0x0000` is impossible on real hardware. It +makes no numeric claim about the three fields `install` owns, and no numeric FPCR default appears +anywhere in the tree. The evidence is EMPIRICAL — the runtime test and the CI assertion — and a +derivation resting on a misread comment would have been this milestone's own family, one level up. + +**WHAT THE MEASUREMENT DOES NOT LICENSE.** Exactly one of the ten sites is exercised by a +witness-producing path: `determinism_main`. The other nine are covered by the deduction (an install +into an already-conforming state is a no-op wherever it runs) and by NO measurement. Stated as two +different kinds of claim, because they are. + +### 2026-08-17 — P1-3 CREATED A DEFECT OF THE MILESTONE'S OWN FAMILY, and the review found it + +`install()`'s doc comment read: «**Called by Tier 0 only**, at exactly three sites, and a fourth one +inside a module is a defect rather than an extension», then enumerated three. P1-3 brought the set to +ten, **three of them inside modules** — the shader watcher's thread, the determinism entry, and the +determinism module's own save/restore test. So the text was false twice over: the count, and the RULE +it stated. + +Fixed by DELETING the enumeration and stating the predicate — every thread start, every process +entry, wherever it lives. Bumping three to ten would only reset the clock on the same defect, and +Guy's ruling on the corpus §4 is the same rule for the same reason: the set is derived and +journalled, not listed in prose. + +**Swept as a class rather than fixed as an instance**, per M1.1.11.1's standing lesson. Four sites, +found by one grep over `src`, `briefs`, `CLAUDE.md` and `.github` for every prose claim about the +number or scope of install sites: + +| site | what it claimed | +|---|---| +| `float_env.zig` `install()` doc | «Tier 0 only, exactly three sites», a fourth in a module a defect | +| `float_env.zig` header | «One definition, three callers» | +| `forge_3d/determinism.zig:11` | «Installing is Tier 0's job … its three call sites» | +| this brief, two entries | «its three call sites»; «§4 … enumerates the three sites» | + +Each corrected in place with its refutation, never replaced silently. + +### 2026-08-17 — GAP TO THE PLAN: the environment assertion's WRAPPER is unwitnessed + +Named and NOT fixed, being outside the closed list of six. Every counter-factual in the tree perturbs +the register and then checks that `checkFloatEnvironment()` returns non-null — they witness the +PREDICATE. No test observes the panic; there is no `expectPanic` or child-process harness anywhere. + +Blast radius, refined by the refutation pass rather than taken as first stated: a **sign-inverted** +mutant would NOT survive — `assert(... != null)` panics in every test reaching `World.init` (which +calls it, so the whole forge_3d solver suite does) or `runCanonical`. Only the **always-true** mutant +survives: `std.debug.assert(true)`, or deleting the call. That is narrower than "any mutation" and +still a real hole, in the guard this very milestone shipped. + +Also recorded, since it bounds every "12 cells" claim in this brief: the matrix job is gated on +`needs.changes.outputs.code == 'true'`. Code changed on this commit and the 12 ran — verified in the +logs, not assumed. + +### 2026-08-17 — P1-1: THE WALK, THE SLOPE, AND THE CHARACTER IN THE WITNESS + +Two halves of one defect, both in `scenario.zig` — the file that DEFINES what the milestone measures. + +**Half one: the header promised "a step and a slope" and the scene held neither.** Nothing stood +near the character but the flat half-space, so `max_slope` was never approached and the deterministic +cosine — the milestone's FIRST behavioural change — was exercised by no witness. + +**Half two: the character reached NO artifact.** `dumpState` and `dumpPoses` walk `s.mobile`, which +holds rigid bodies, and a virtual character owns none. The controller ran 1000 frames — swept, +depenetrated, classified its ground — and every bit was discarded. + +**AND THE GUARD WRITTEN FOR EXACTLY THIS CASE COULD NOT CATCH IT.** The `mobile.items.len == 12` +assertion carried the comment "fails when an element is added without being appended to `mobile`, +which would silently shrink the continuous metric's coverage". The character WAS added and not +appended, and the test stayed green because whoever added it updated the body total in the same +commit. A count pinned alongside the change it is meant to catch catches nothing; what it guards is +arithmetic drift between two numbers. The comment now says that, and what covers the real case is a +DISCRIMINATION on the stream. + +**THE DESIGN WAS MEASURED INTO EXISTENCE, and every round refuted something I had written.** + +1. **Rotated boxes as ramps: abandoned after three failures.** A box rotated about +Z has a footprint + wider than its half-extent by `|sin| · h`, so its leading edge is a corner at a height a reader + must derive; its Z faces stay VERTICAL, and a vertical face is never ground whatever `max_slope` + says, so a character arriving along Z is blocked with the slope test never running — my first + placement relied on exactly that crossing; and one placement floated a wedge 0.25 m above the + plane and **wedged the character in the crevice underneath for 775 of 1000 frames**. Mesh ramps + have literal vertices: the surface is where the numbers say and two ramps share a crest vertex to + the bit. +2. **The scenario walked below the threshold of its own step arm.** `tryStepUp` lifts by + `step_height` then advances by the motion REMAINING in that tick, so climbing depends on the + caller's per-tick step and not on the riser alone. Swept: at **0.03 m/tick — the walk this + scenario used — NO riser is climbed at any height**; 0.15 m needs 0.06, 0.25 m needs 0.10. Not a + controller defect; invisible without the sweep. Walk is now 0.06 with a 0.15 m riser, the cheapest + pair that exercises the arm. +3. **A COMMANDED LOOP IS NOT A CLOSED LOOP.** Climbing costs forward progress, so a `+x` leg ending + 2.2 m short is followed by a `−x` leg spending all of it: 2.2 m of drift per cycle, for ever. At + ten times the frame count the character is 55 m from its terrain — and this scenario is an + INSTRUMENT that M1.1.25 and M1.A replay. Closed by GEOMETRY, a fourth ramp making a bowl, rather + than by tuning leg lengths against the climb — a number that would stop matching the moment an + angle changed. Measured after: `x ∈ [−67.855, −60.188]`, no drift over 1000 frames. +4. **I concluded a 63.4° ramp was unclimbable-when-permitted. The measurement refuted it.** An + isolated sweep climbs it to `y = 2.01` when the cosine allows. What blocked the second direction + was my `+x` LEG LENGTH, spent before the ramp: at 150 frames the discrimination was 0.23 m, at 300 + frames it is 1.72 m. Had I trusted the first reading I would have written a false explanation for + a real number. +5. **The coverage loop ran 400 frames against a 700-frame script period** — structurally blind to + half the script, which is how the riser clause failed on a character that had climbed it at frame + 750. A coverage test shorter than the period it covers has a blind half. +6. **An assertion on a FINAL value depends on where the loop stops.** `y > 0.5` at frame 400 failed on + a character that had climbed to 0.95 and come back down. The claim is a property of the + trajectory, so the clauses accumulate over the run. + +**THE BRACKET, AND IT BITES BOTH WAYS — measured, f32, 1000 frames:** + +| `max_slope` | `cos_max_slope` | `max_y` | x span | +|---|---|---|---| +| 0.400 | 0.9211 | **0.0063** | 2.7 m — never leaves the plane | +| 0.785 | 0.7074 | **0.9463** | 7.7 m — the default | +| 1.200 | 0.3624 | **2.6707** | crests both steep ramps and escapes the bowl | + +Surface cosines `0.894` and `0.6247` bracket `cos(0.785) = 0.70739` by `0.187` and `0.083`, the +tighter being 700 000 f32 epsilons — both verified against an independent computation, not read off +the vertex table. A cosine wrong in its first decimal is not a rounding difference in this witness, it +is a different scene. Shipped as a test, with the flattened-ramp counter-factual RUN: both new tests +fail on it. + +**BOUNDED, and stated rather than implied: `GroundState` is `.grounded` for all 1000 frames.** The +steep ramps are barriers the character butts into, not surfaces it bears on, so what carries the +cosine into the witness is the POSITION. The verdict field is serialised anyway — it is the +authoritative controller output and it CAN vary — and its presence is pinned separately: removing the +whole character block fails the discrimination test, and removing ONLY the verdict fails it too. + +**A FALSIFIABLE PREDICTION ENTERING THE REGENERATION.** Only the four continuous-chain witnesses +should change. The four discrete traces and both reference windows are derived from the rigid +simulation, and the terrain is static and far from every dynamic body while the character is virtual — +so nothing rigid moves. VERIFIED LOCALLY at all four corners before regenerating: `trace verdict OK` +×4 and `divergence frame none` at both precisions and both modes, and a local generation reproduces +the four ISA-independent witnesses BYTE-IDENTICALLY. It follows that the four ARM cells should stay +GREEN through the stale-witness push, their comparison never touching the chain. If a discrete witness +moves, something perturbed the rigid simulation and that is a finding, not a re-baseline. + +### 2026-08-17 — THE WITNESS RE-BASELINE, and the prediction held to the octet + +Run 32026918193 on `e851ff0`. **The prediction written into that commit's message before the run was +confirmed cell by cell and file by file.** + +| predicted | measured | +|---|---| +| the 4 ARM cells stay GREEN through the stale-witness push | 4 of 4 **success** | +| the 8 x86_64 cells fail on the chain | 8 of 8 **failure** | +| the 4 continuous-chain witnesses change | 4 of 4 **CHANGED** | +| the 2 discrete and 2 reference-window witnesses are byte-identical | 4 of 4 **IDENTICAL** | + +That separation is not a convenience, it is the artifact model working: the terrain is static and far +from every dynamic body, the character is virtual, so NOTHING rigid moved — the four discrete traces +and both windows are derived from the rigid simulation and could not move. The ARM cells stayed green +because their comparison never touches the chain, level 1 being intra-ISA. Had a discrete witness +moved, it would have been a finding and not a re-baseline; the prediction is what made the difference +visible in advance rather than arguable afterwards. + +**FIRST REAL USE OF THE `Witness-regen:` MECHANISM, and it worked in the case it exists for** — which +is precisely what P1-4 had to fix first: before that fix, generation exited 1 whenever the committed +witnesses differed, i.e. always, when regenerating. Here the trailer was read from the head commit, +the job generated all four sets, and cross-mode agreement was re-measured and holds at BOTH +precisions in the new set (`f32 Debug == ReleaseSafe`, same at f64). `PROVENANCE.txt` carries the +motive, the generating cell, the CPU pinning, the reported `zig version`, the per-file mode, the head +sha `e851ff0` and the run URL. `SHA256SUMS.txt` verified against the committed bytes: 8 of 8 OK. + +Local four corners green against the new set. + +### 2026-08-17 — P1-2: THE COMMITTED BIT TABLE, WITH AN ORACLE THAT IS NOT `@cos` + +The pre-existing accuracy test compares against `@cos` and its own comment concedes the limit: it +proves the function is STABLE, it cannot prove the function is a COSINE. Two implementations of the +same wrong idea agree. So the milestone's first behavioural change — replacing `@cos` at the +`max_slope` conversion — had no pin on the VALUE it produces. + +**THE ORACLE IS EXTERNAL AND ARBITRARY-PRECISION**: pi to 80 digits by an alternating arctan series in +exact decimal arithmetic, no floating point anywhere on the value path, **computed TWICE by different +Machin-like formulas and required to agree to 70 digits** — `16·atan(1/5) − 4·atan(1/239)` against +`20·atan(1/7) + 8·atan(3/79)`. Each argument taken as its EXACT binary value (the f32 row is the +cosine of the f32-ROUNDING of the literal, not of the literal), reduced by an exact integer quotient, +then the Taylor series; the result rounded by comparing BOTH neighbours explicitly, since a single +conversion would inherit its own rounding and a Decimal → f64 → f32 path can double-round. + +**The tool is NOT in the repository and the recipe is** — same arbitrage as the float-environment site +list, and for the same reason: this repository is Zig, `tools/` holds Zig, and a reader who can +re-derive does not have to trust. + +**TWO COLUMNS, TWO CLAIMS, said plainly because conflating them is this milestone's own defect +family.** `oracle` is the correctly-rounded true cosine and carries CORRECTNESS. `engine` is what the +implementation emits and carries REPRODUCIBILITY across the twelve cells. The `engine` column is +self-generated and validates nothing about accuracy. + +**MEASURED, and one result is stronger than expected:** + +| | f32 | f64 | +|---|---|---| +| agreement with the correctly-rounded oracle | **12/12, bit for bit** | 9/12 exact | +| worst absolute error | ≤ ½ ULP by construction | **3.14 eps**, at `x = 1 647 099` | + +At f32 the implementation is correctly rounded on every argument in the table — so the oracle column +doubles as the reproducibility column there and no separate engine column exists for it. + +**AND THE BOUND IS ABSOLUTE, NOT IN ULP, which is a measurement and not a preference.** At the f64 +nearest pi/2 the true cosine is `6.12e-17` — near-total cancellation — and the implementation's error +there is **1.6e11 ULP** of that value while being **2.0e-21 in absolute terms, the SMALLEST absolute +error in the whole table**. A ULP bound would fail spectacularly on the most accurate row. Cosine is +bounded by one, so an absolute bound is its natural accuracy statement. Budget `4 eps` against a +measured worst of 3.14 — 78% used, so it is neither a restatement of the measurement nor wide enough +to admit a regression. + +**ANTI-VACUITY IS ASSERTED, not assumed**: if `engine` equalled `oracle` on every row the absolute +bound would hold by construction and measure nothing. Written as a property of the table rather than a +count, so adding a row cannot silently make it vacuous. + +**Four counter-factuals RUN, and the fourth was MIS-DESIGNED at first — recorded because the lesson is +general.** Perturbing an oracle f64 bit fires the correctness test; perturbing an engine bit fires +reproducibility; perturbing an oracle f32 bit fires both f32 claims. My first anti-vacuity probe +changed the `engine` column to match the oracle — which trips REPRODUCIBILITY first and never reaches +the clause under test. A counter-factual must perturb what the targeted guard judges, and in a shared +table one perturbation can trip a different guard on the way. Redone by aligning the ORACLE column to +the engine on the three differing rows: reproducibility passes, correctness passes, and the +anti-vacuity clause is the only thing that falls. + +### 2026-08-17 — P1-5 and P2-6: the fourth trace becomes an oracle, and an absent window stops reading as a match + +**P1-5 — THE RETAINED PAIR SET DOES SHRINK ON THE CANONICAL SCENARIO, and it took a measurement to +say which claim was missing.** `trace.zig` states the hazard on itself: over a set that can only grow, +a trace agrees with itself by ACCUMULATION. The harness was pruned earlier in this milestone so that +stops being true, and `solver_test.zig` has a generic departure test — but the pruning being +IMPLEMENTED and this scenario EXERCISING it are two claims, and only the first was established. + +Measured: exactly ONE removal in 1000 frames, at frame 196, and it is **the static `MeshShape` against +the frictionless sphere crossing it**. The sphere carries a fixed 3 m/s, leaves the mesh at x = 65 +around frame 180, and its fat AABB separates around 196 — permanently, nothing bringing it back. So +the event is a consequence of element 5's design and cannot quietly stop happening while that element +still does what it is for. + +**AND THE ASSERTION IS ON THE SET, NEVER ON ITS CARDINALITY, which is measured rather than stylistic.** +The removal at 196 is followed by an ADDITION as the same sphere reaches the ground plane, so the size +returns to 11. My first probe was size-based and saw the dip ONLY because the two events land on +different ticks; had they coincided it would have reported nothing while the removal happened. What is +required is that a key present once be absent later. + +The test also names the pair rather than accepting any removal — built from the live handles, since a +`BodyId` is generational and not a slot number — because otherwise a removal caused by anything at all +would satisfy it and it would stop being evidence about pruning. Positive witness first: the set is +non-empty and at least two distinct keys were seen. **Counter-factual RUN**: neutralising +`pairStillOverlaps` in the harness fails both the generic test and this one. + +**P2-6 — `divergenceFrame` ANSWERED `none` ON ABSENT DATA.** The loop carried +`if (off + stride > reference.len) return null;`, so an EMPTY, TRUNCATED or wrong-precision reference +returned `null` — which the entry point prints as `divergence frame : none within K=60`. Absent data +read as a perfect match, in the function that produces the milestone's level-2-point-2 number. + +Two outcomes now separated: `null` means measured and no divergence, an error means the input was not +a window and NO measurement was taken. The length is required EXACTLY, which also catches a reference +of the other precision — the f64 window is exactly twice the f32 one and neither length divides the +other's — and a zero stride is a distinct error because `0 * window_frames == 0` would let an empty +reference pass the length test as a match. **The in-loop bounds test was REMOVED rather than kept**: +with the entry check it cannot fire, and a guard whose only reachable branch is the one nobody wanted +is what let the silent `null` live. + +Test covers all four shapes with a positive witness first — a self-comparison must still measure and +report no divergence, the case the guard must not break. Counter-factual RUN: restoring the silent +early-return fails it. Real path re-verified at four corners, all four still measuring. + +### 2026-08-17 — TOOLING FACT, self-reported: a status predicate must ENUMERATE, never negate + +Twice in one session my own CI poll reported cells as FAILED that were not, and both times from one +cause: `conclusion != "success"`. GitHub's job `conclusion` is `""` while a job runs and `"cancelled"` +when a run is superseded, and a negated predicate swallows both into "failed". + +The consequences were not academic. The first instance printed eleven FAILED beside a summary line +reading `0 failure / 11 pending` — the two halves of my own output contradicted each other, which is +the only reason it was caught. The second produced a **false alarm attributing nine failures to P1-2**, +the cosine bit table, and I began reasoning about FMA contraction on AArch64 before checking the state +name. The cells had been CANCELLED because I pushed the next commit while the previous run was still +going; P1-2 was not implicated in any way. + +**The rule: a status predicate enumerates the states it distinguishes.** `success`, `failure`, +`cancelled`, `skipped`, `running` — each counted by name, and an unrecognised value bucketed as +`other` rather than folded into the nearest familiar one. Written that way now. + +Related, and worth stating beside it: **pushing while a run is in flight destroys that run's evidence.** +The superseded run of `6f4dc46` had completed exactly two cells before cancellation — +`ubuntu-24.04 / Debug` at both precisions, both GREEN, and both chain-comparing x86_64 cells, so the +re-baselined witnesses are confirmed on the two cells that got to speak. The other ten never ran. If a +push's verdict matters, the previous run has to be read before the next push, not after. + +### 2026-08-17 — MATRIX VERDICT AFTER THE SIX CORRECTIONS: 11 of 12, and the twelfth did not contradict it + +Run 32029320438 on `d627b89`, the head carrying all six corrections and the re-baselined witnesses. +**11 cells success, 1 failure: `windows-2025 / ReleaseSafe / f32`.** + +**THE SIGNATURE WAS VERIFIED BEFORE THE KNOWN FLAKE WAS INVOKED**, because attributing a real failure +to a familiar flake is the same defect family this milestone is about. What the log establishes: + +- `error: test runner failed to respond for 1m13.052ms` — a HANG, not a wrong answer. +- `Build Summary: 272/274 steps succeeded (1 failed); 1831/1863 tests passed (32 skipped)` — the counts + reconcile with **no failed tests at all**, and a grep for `TestExpected` / `TestUnexpected` / + `assertion failed` / `panic:` over the whole 755-line log returns **ZERO**. +- `run test 552 pass (552 total)` — **the forge suite passed IN FULL on that cell**, the new P1-5 test + included. The determinism step never ran, `zig build test` having failed the job before it. + +That matches the recorded `windows-2025 / ReleaseSafe` hang exactly, and the job was re-run rather than +the code suspected. + +**THE RE-RUN SETTLES IT: 12 of 12 cells green, run conclusion `success`.** And the DISCRIMINATING check +was made rather than the green taken at face value — the first attempt never reached the determinism +step at all, so "the cell passes" and "the cell actually compared the chain" are two different claims. +On the re-run that cell printed `chain verdict : OK (1000 frames)`, all four trace verdicts OK, and +`divergence frame : none within K=60`. Its `Build Summary` reads `274/274 steps succeeded; +1832/1864 tests passed (32 skipped)` — 1832 + 32 = 1864, which is the Windows total independently +recorded in `CLAUDE.md`, so the two agree without either having been derived from the other. + +**WHAT THE 11 GREEN CELLS ESTABLISH, stated narrowly.** The re-baselined chain witnesses hold on every +x86_64 cell that ran. The four discrete traces and the reference window hold on all 12 including the +four ARM ones. **And the cosine bit table holds across three operating systems and two architectures — +which no single machine can establish**, and which was the whole point of committing it: a value pinned +on one host is a pin on that host. + +Also recorded: the many `failed command:` lines in that log are the deterministic +stderr-on-a-passing-Run-step phenomenon already characterised in `CLAUDE.md`, not failures. Judging +this log on their presence would have produced the opposite error to the one avoided above. + +### 2026-08-17 — CLOSING LANGUAGE AUDIT, scoped to what this milestone touches + +Run with a Python regex over the 71 files changed against `main`, never a `grep` bracket class — the +byte-wise class reports a clean tree over files that demonstrably contain French, a fact already +recorded here. **First attempt was run over the WHOLE TREE and returned 390 accented lines**, which is +the wrong scope: most are closed brief records that are not retro-patched. Re-scoped to the diff. + +**Verdict on this milestone's artifacts: clean.** 58 code/CI files give 13 accented lines and 3 +markdown files give 16, and every one is admissible: + +- **verbatim spec citations**, in italics or quotes — `*Métriques*`, `*Sources de vérité*`, + `*Threading*`, `*Types mathématiques*`, `Conséquences`, `"à ISA, build, configuration et nombre de + workers identiques"`, `"l'unique restart autorisé"`; +- **a proper name** — Möller–Trumbore; +- **an English loanword** — `façade`; +- **`Étape`**, the prompt template's own section name. + +**Six lines of genuine French PROSE in `ci.yml` are PRE-EXISTING**, from `aad6894d`, an ancestor of +`main` — established by `git blame` per line and an `is-ancestor` check, not inferred from the code's +apparent age. The `changes` job's paths-filter rationale and the `ci-gate` aggregation note. Outside +the closed list of six corrections, so a named plan entry rather than a fix here. + +### 2026-08-17 — THE HEAD IS VERIFIED, and two of my expectations about CI were wrong + +`125bb89`, the Gate F documentation commit, ran the FULL matrix and came back **11 of 12**, the twelfth +being infrastructure. Two corrections to what I expected, both measured: + +**1. A docs-only commit does NOT skip the heavy cells, and the reason is structural.** I predicted it +would. On a `pull_request` event the `changes` job compares the PR **BASE** — `main` — against the head, +not the previous push, so the diff it classifies is the whole PR. A branch that touches code therefore +runs the full matrix on every push, whatever the individual commit changed. That is the CORRECT +behaviour for a merge gate, since the merge lands the whole diff; my expectation was a misreading of the +mechanism, and the consequence is welcome: **the head is verified rather than assumed.** + +**2. The one failure is a THIRD distinct infrastructure cause**, not the Windows hang and not a +cancellation. `ubuntu-24.04 / Debug / f32` died in **`Set up job`** on +`429 (Too Many Requests)` fetching the `weldengine/setup-zig` action archive — a 36-line log, zero +assertions, before a single line of Weld compiled. Partly self-inflicted: five pushes and two job +re-runs inside an hour, times a 12-wide matrix, each cell fetching that archive. Job re-run. + +**The discriminator is the same for all three, and it is worth stating once:** grep for `TestExpected` / +`assertion failed` / `panic:` and read the `Build Summary` counts. Zero failed assertions plus a +reconciling count means the code did not answer wrongly — something stopped it, or never started it. +Three different things stopped it today, and each would have been misread as a code failure by anyone +judging on the exit code alone. + +**The verdict of record for the corrected milestone is `d627b89`: 12 of 12, run conclusion `success`, +with the previously-hung cell verified to have actually compared the chain** and not merely passed. + +### 2026-08-17 — P2-7: THE CONSERVATION WAS NEVER EXECUTED, and it is the fourth instance + +**Guy's finding, and it is a P1.** The conservation `live_tests = collected total` is what he declared +to be THE GATE authorising the dead-test guard's activation — not its twenty-one fixtures, the +conservation. **It was not wired.** `weld_lint` computed it only when handed +`--expect-collected=N`, and **neither `build.zig` nor the CI ever passed it**: the loop is guarded by +`if (!std.mem.startsWith(u8, a, prefix)) continue;` over `argv_extra`. The tool printed +`expected collected` and then printed `clean`, having compared its expectation to nothing. + +**And the number was right BY ACCIDENT.** `closure 1864 − 5 = 1859` is arithmetic on the closure and +the declared gap — it never touched the suite. 1859 was indeed the collected total, and nothing +compared them. A verdict was read where there was only a display, and it was accepted three times. + +**Fourth instance of "a control that exists and a path bypasses"** — after the lint step in no +workflow, the witnesses with no reader, and the cache save outside its own size guard. This one inside +the tool built against that family. + +**THE FIX IS TWO LAYERS, and only the second is optional — because an optional check IS the defect.** + +1. **Unconditional, no flag to forget.** `dead_tests.zig` gains `expectedCollectedOn(os)` — 1866, and + 1864 on Windows — beside `uncollected`, and `main.zig` confronts `live_tests − gap` against it on + EVERY invocation, local and CI, failing with a message that forbids the obvious wrong repair: + bumping the declared number to match a drifted closure is what turns the control into arithmetic on + itself. +2. **Suite-derived, from CI.** The `zig build test` step now captures the collected total from the + summary of the invocation that ACTUALLY RAN the tests — `tee`, not a re-run — and a new step passes + it as `-Dexpect-collected`. That is what stops the declared number from being aligned to a drifted + closure: two independently produced numbers, which is what the bilateral control has meant since it + was written. Absent or unparsable is a FAILURE and not a skip, a missing number being how a control + comes to be bypassed. The exit code is read through `PIPESTATUS`, since a pipeline's status is the + last command's and this repository has pushed a red build under a green self-report exactly that way. + +`build.zig` forwards `-Dexpect-collected=N` when given. It is optional there and the reason is stated +at the site: `zig build lint` cannot run the suite to learn the figure, and an option silently +defaulting to the closure's own arithmetic would be the defect again in a new costume. What covers the +forgotten case is layer one. + +**Measured now: closure 1871 − 5 = 1866 on macOS, and the suite reports `1847/1866`. They agree.** The +conservation was TRUE all along; nobody was checking it. + +**Counter-factuals RUN, both layers, locally:** a declared 1867 gives `CONSERVATION FAILED — closure +gives 1866 … declares 1867`, rc=1; `-Dexpect-collected=1865` gives `CONTROL FAILED — expected 1866 +collected, zig build test reported 1865`, rc=1. The CI-cell counter-factual is a separate, deliberately +red push, since Guy requires the cell to redden and not merely the local invocation. + +**AND WIRING THE CONTROL BROKE THE LINT, in a way worth recording.** Inserting +`expectedCollectedOn` immediately above `uncollectedOn` placed it BETWEEN that function and its own +`///` doc comment, so the new function inherited the comment and `uncollectedOn` lost it — +`doc_comments: missing /// doc comment on public declaration`. Caught by `zig build lint` before the +push, which is the tree-wide run the `pre-commit` hook cannot do (it sees staged files only). The +comment is restored verbatim from `HEAD`. + +**THE FIRST WIRING LEFT A HOLE, FOUND BY READING ITS OWN GREEN.** The conservation step was placed on +`ubuntu-24.04 / Debug`, copying the arbitration written for `zig build lint` — one cell, because the +lint RULE pass answers a property of the SOURCE. **The conservation is not that kind of quantity.** The +collected total is PER PLATFORM: 1864 on Windows against 1866 elsewhere, the two `only_on = .windows` +entries of `uncollected`. So on one cell the branch `expectedCollectedOn(.windows)` was a declared +number that NOTHING confronted — the exact shape P2-7 exists to remove, reproduced inside P2-7's own +fix, and visible only because the cell log printed `1866` and prompted the question of who checks 1864. +The step now runs on all twelve. It is a source scan plus one small build, so twelve times is cheap +against a per-platform number unchecked on two platforms of three. + +**Positive witness on the cell, before any deliberate red:** `suite reported 1866 collected tests`, +then `conservation OK … agree at 1866` inside the `lint` step, then in the new step +`control OK — closure and suite agree at 1866`. Both layers execute on a real cell, and the CI parser +works on a real Linux summary and not only on a fixture I wrote. + +### 2026-08-17 — BIT-NEUTRALITY, ATTACHED TO ITS OBJECT + +Guy: the result had no object, and "a stronger deduction" is the shape of sentence this milestone spent +two weeks dismantling. He is right, and `d627b89` was the wrong SHA to name — it carries P1-1, so its +witnesses are regenerated, so it is not the window. Three identifiers, flat: + +| | | +|---|---| +| the P1-4 + P1-3 head | **`2c29c7c5193209bdd8694995221c6306cab9eb57`** | +| the matrix run on that head | **`32019448000`**, `completed` / `success`, **12 of 12 cells**, `forge-determinism` step present on 12 of 12 by enumeration | +| the eight witness files in the diff `9e23dd9..2c29c7c` | **none of them.** Nine files in that diff and not one under `witnesses/` | + +The witnesses were **unchanged in the diff** over that interval. Twelve green cells on `2c29c7c` +therefore establish the bit-neutrality of the installation, on three operating systems and two +architectures. That is the measurement, and the window closed at `e851ff0`. + +### 2026-08-17 — P1-1: THE SCOPE PROBE NOW ASSERTS ARRIVAL, AND ONE CLAUSE DID NOT + +Guy: the letter of the finding — "no step and no slope nearby" — is satisfied by a step and a slope the +character never reaches. Audited clause by clause, and **he is right about one of the three**: + +- `char_max_y > 0.85` — only the walkable ramp gives that height. Asserts arrival. **Held.** +- `char_on_riser`, a POSITION BAND around the tread — asserts arrival, but through a proxy that holds + only while the rectangle stays correct. +- `char_max_x < -60.0` — **SATISFIABLE BY ABSENCE.** A character that never came near the steep ramp + passes it just as well. That clause asserted a refusal without asserting the encounter. + +Repaired, and the position proxies replaced by the CONTACT itself: `Scenario.step` was discarding +`moveCharacter`'s result (`_ = …`), which is why no test could name what the character stood on. It is +captured now, and the clauses read `ground.body` — the controller's own answer: + +- **stood on the riser**, by `ground.body == step_block`, so `tryStepUp` demonstrably fired; +- **stood on the walkable ramp**, by `ground.body == walk_slope`, plus the height clause so one tick of + contact at its foot is not enough; +- **the steep ramp two-sided**: `> −60.4` is the ARRIVAL (base at −60, capsule radius 0.3, held near + −60.19), `< −60.0` is the refusal. + +**Three counter-factuals RUN, one per clause, each firing on its own:** riser out of the lane → +`stood_on_riser`; walkable ramp out of the lane → `stood_on_walk_slope`; steep ramp moved 20 m away → +`char_max_x`, which is the new arrival bound and it bites. + +**And my probe harness reported a VACUOUS counter-factual as a pass.** The riser substitution did not +apply — the pattern had changed under `zig fmt` — the python exited non-zero, the shell did not check +it, and the printed `rc=0` was the control run. Same family, in the apparatus, for the fifth time +today. The harness now fails loudly with `SUBSTITUTION DID NOT APPLY — probe is VACUOUS, not a pass`. + +### 2026-08-17 — THE DELIBERATE RED, and what it established and did not + +Two runs on one pair of numbers, the only evidence that separates "the control executes and prints OK" +from "the control works" — a step that prints OK whatever it is given is indistinguishable from a +working one on a green run, which is exactly what P2-7 WAS. + +| | run | verdict | +|---|---|---| +| green, honest total | **32041901826** | 8 cells reached the conservation, 8 printed `control OK`; `windows-2025 / Debug / f64` printed `suite reported 1864` and `control OK at 1864` | +| **red, `+ 1` on the passed total** | **32044416532** | **12 of 12 cells FAILED** | + +**On the 8 non-Windows cells the prediction held at the character**, and the two layers are visibly +independent in one log: `suite reported 1866 collected tests`, then +`conservation OK — closure and the declared suite total agree at 1866` (layer one, tree untouched), then +`CONTROL FAILED — expected 1866 collected, `zig build test` reported 1867` (layer two). + +**THE WINDOWS HALF OF MY PREDICTION WAS WRONG, and the cause is in my counter-factual and not in the +shipped wiring.** I predicted `1864 vs 1865` there. The step carried no `shell:`, so on windows-2025 it +ran under `pwsh`, where `$(( 1864 + 1 ))` becomes `$ 1865` — two tokens — and `zig build` answered +`error: Expected -Dexpect-collected to be an integer of type usize`. The three Windows cells that +reached the step therefore failed on ARGUMENT PARSING, not on the conservation. **The negative witness +on Windows is NOT established**, and the positive one is: the green run's `control OK at 1864`. + +**The fault did find something real about the shipped step**: it was shell-dependent by omission. +Harmless for a bare substitution, a trap for whoever next adds shell syntax. `shell: bash` is declared +in the revert, uniform across the twelve, which also makes a Windows negative witness obtainable should +one be wanted. + +**Two of my own slips in this experiment, both the same shape.** `git checkout ` restores from the +INDEX, and the index already held the fault I had staged in the first attempt — its output said +`Updated 0 paths from the index` and I went on. Repaired with `git restore --source=HEAD` and then +VERIFIED ON THE CONTENT, `else => 1866` read back and the local conservation re-run. And the first fault +could not be committed at all: the `pre-commit` hook runs `dead-tests`, so it REFUSED a faulted +declaration — a third witness for layer one, on the hook, beside the local invocation and the cell — +and `--no-verify` is never taken, which is what moved the fault onto the layer P2-7 is actually about. + +### 2026-08-17 — THE FINAL HEAD, VERIFIED ON THE CELL LOGS + +`a4f6e82`, run **32048243472**, **12 of 12 cells success**, `forge-determinism` step present on 12 of 12 +by ENUMERATION over each job's own step list rather than inferred from the matrix. + +**The revert is verified on the CELL LOG and not on the diff**, which is the check a revert of this +shape actually needs: one that restores incompletely — the declared number and the passed number moving +together — would go green again and the diff could not see it. What is read back is the PAIR OF NUMBERS, +per cell, and it differs by platform: + +| cells | read back | +|---|---| +| `ubuntu-24.04` ×4, `ubuntu-24.04-arm` ×4 | `suite reported 1866` and `control OK … agree at 1866` | +| `windows-2025` ×4 | `suite reported 1864` and `control OK … agree at 1864` | + +That single table settles both open points at once. The numbers are the honest ones, so the revert is +complete. And `expectedCollectedOn(.windows)` is confronted on all four Windows cells — a branch that +NOTHING confronted until the hole inside P2-7's own fix was found and closed. + +**THE COMPLETE EVIDENCE FOR P2-7, in the order it was obtained**, since the point of the exercise was +that no single one of these suffices: + +| witness | where | what it shows | +|---|---|---| +| local, layer one | `zig build dead-tests`, declared 1867 | `CONSERVATION FAILED`, rc=1 | +| local, layer two | `-Dexpect-collected=1865` | `CONTROL FAILED`, rc=1 | +| the `pre-commit` hook | first fault attempt | REFUSED the commit — layer one live on the hook, and why the fault moved to layer two | +| green cells | run 32041901826, then 32048243472 | both layers print OK on real cells, all three platforms | +| **red cells** | run **32044416532** | **`CONTROL FAILED` in 8 cell logs, layer one simultaneously OK in the same log** | + +The red is the one that cannot be substituted, and its absence is exactly what P2-7 was: a control that +existed, displayed, and confronted nothing. + +### 2026-08-17 — THE SECOND NEGATIVE WITNESS: the Windows constant is confronted + +The exact complement of the first, and it kills a different hypothesis. Not "the step runs on Windows" +— the positive witness had that — but **"the comparison is TAUTOLOGICAL there"**: both halves drawn from +one source, printing `1864` / `control OK at 1864` and staying green whatever happens. That is the P2-7 +class itself, and a green run cannot distinguish it from a working control. + +**The fault was on the declared Windows constant, at its site, and nothing else** — +`expectedCollectedOn(.windows)` 1864 → 1865. Not the passed argument, not the shell, not a shared value. + +**It is invisible from macOS**, which is both the mechanism and the explanation of its survival: this +host reads the `else` branch and prints `conservation OK … agree at 1866`, so the `pre-commit` hook — +which runs `dead-tests`, and which REFUSED the first fault attempt — let this one through. + +| | SHA | run | measured | +|---|---|---|---| +| red #1 | `d237f82` | **32044416532** | 8 non-Windows cells RED on layer two, `CONTROL FAILED — expected 1866 collected, reported 1867` | +| red #2 | `8116831` | **32063230404** | **4 Windows cells RED on layer one**, all four printing BOTH numbers: `CONSERVATION FAILED — closure gives 1864 expected collected, expectedCollectedOn(windows) declares 1865`; **8 non-Windows cells GREEN** at 1866 on both halves | + +Failing step named on all four: `dead-test conservation against the suite's own total`. Not +infrastructure — the discriminator applied before reading anything into it. + +**COVERAGE, stated exactly rather than rounded up.** Red #1 killed the tautology of layer TWO on the +eight non-Windows cells; red #2 killed the tautology of layer ONE on the four Windows cells. Together: +both layers, all twelve cells, and **no cross-platform contamination in either run** — the conservation +being per platform, that non-contamination is the property needed, not twelve reds. **NOT covered**: +layer two on Windows, and layer one on non-Windows. Each of those has a positive witness only, and +saying so is the difference between a union and a union presented as complete. + +### 2026-08-18 — P1-2, P2-3, P1-1: THREE FINDINGS, AND THE ORDER WAS THE POINT + +Guy's order was imposed and its reason held: regeneration had to work BEFORE P1-1 made it necessary, +or it would have failed exactly there. Third time that ordering imposed itself on this milestone. + +**P1-2 — REGENERATION STILL FAILED WHERE IT SERVES, and the earlier fix only covered the easy half.** +The `rc=0 / rc=1 / rc=0` triplet exercised the path where the FORMAT is stable. The real case is a +STRUCTURAL change: one more mobile, a different `pose_stride`, and the old `reference-window` makes +`divergenceFrame` raise `ReferenceWindowLengthMismatch` — an ERROR, not the `failed` boolean that was +being neutralised. **Reproduced before fixing**, with one extra scalar per body in the pose dump: +`rc=1`, `error: ReferenceWindowLengthMismatch`, **three files already on disk**. Under +`set -euo pipefail` the CI step dies after writing and before uploading. + +Fixed: the generation path writes and RETURNS. It reads no committed witness at all — not to compare, +not to validate a length. **Verified on the same structural change: `rc=0`.** + +**P2-3 — THE GATE CAME AFTER THE ACT IT PREVENTS.** Files were written, then +`NotSelfReproducible` was returned, so a non-reproducible run left a complete and usable witness set on +disk — the exact inverse of the comment beside it. The gate now precedes every side effect. Verified +with a GENUINE non-reproducibility (the second run perturbed in `runCanonical`, not the boolean +forced): `rc=1`, `NotSelfReproducible`, **zero files written**. + +**P1-1 — THREE OF THE FOUR DISCRETE TRACES WERE CONSTANT IN THE COMPARED WINDOW.** The probe searched +400 frames and found a removal at 196; the committed `discrete-*.bin` hold `window_frames` = 60. So +inside the window that is actually compared, island partition, sleep state and the retained pair set +each took **exactly ONE value**. They agreed between x86_64 and AArch64 because they did not move. A +probe measuring one window and rendering a verdict on another — the milestone's family, reaching the +central oracle. + +**AND MY FIRST MEASUREMENT OF IT WAS ALSO THE WRONG INSTRUMENT.** It counted cardinalities and reported +the manifold trace as constant at 11, while the SERIALISED segment takes 7 distinct values over the +same frames. A count is not the trace. Redone on BYTES, per trace, per frame. + +| trace | distinct byte-values over the 60 compared frames | after tuning | +|---|---|---| +| island partition | **1** | **2** | +| sleep state | **1** | **2** | +| per-pair manifold cardinality | 7 | 12 | +| retained pair set | **1** | **2** | + +**Scene tuning, because a trace's variation is a property of the SCENARIO and not of the physics.** +Measured first: islands first moved at frame 71 and the first sleeper appeared at 70 — the same event +one tick apart, a sleeper leaving the partition, so ONE intervention covers both — and the first +retained-pair removal was at 196. After: **30, 29, 49**. Three changes: the two colliding groups moved +from x = 30/42 to 35/38 so contact lands inside the window; the mesh sphere from 3 to 12 m/s so it +leaves the mesh's fat box inside it; and a LONE BOX at x = 15 whose only job is to settle at once and +sleep early, isolated where nothing passes. + +Shipped as a test on `run.window_frames`, asserting **per trace** and not as an aggregate — an +aggregate is satisfied by one trace moving twelve times while three stand still, which is the state it +exists to end. Counter-factual RUN: removing the lone sleeper gives +`trace 'sleep state' takes 1 distinct value(s) over 60 frames`. A second counter-factual on the window +pin fires with `expected 0, found 388`. + +**AND ONE COUNTER-FACTUAL OF MINE WAS VACUOUS, reported as such.** I tried to make the test read a +400-frame run; the discrete stream only ever holds `window_frames` frames by construction, so the +argument changes nothing and the `rc=1` came from the witness tests already red. Replaced by the +loop-bound counter-factual above. + +**THE CONSERVATION I WIRED YESTERDAY STOPPED ME, on its first real use.** Adding the non-vacuity test +took the closure to 1867 against a declared 1866: `CONSERVATION FAILED`. Its message forbids the +obvious repair, so the constant was reconciled against `zig build test --summary all`, which reports +**1867** collected — not against the closure's own arithmetic. Windows follows at 1865. + +### 2026-08-18 — A REGENERATION DEADLOCK THE PREVIOUS ONE DID NOT MEET + +The `pre-push` hook runs `zig build test`. A scenario change that MOVES the discrete traces makes the +two witness-comparison tests genuinely red, so the hook refuses the push — and the authoritative +regeneration runs only in CI, which needs that push. `--no-verify` is never taken. + +The previous regeneration slipped past this by accident: its scenario change left the discrete traces +byte-identical, so the tests stayed green. This one moves them deliberately, which is the whole point +of P1-1, so the deadlock is structural and will recur on every trace-moving change. + +Way out taken: an INTERMEDIATE commit, green locally and explicit about being provisional. Its +`PROVENANCE.txt` states that two of the three kinds are wrong for their purpose — the chain is +intra-ISA and belongs to x86_64, the reference window is the baseline the ARM cell measures its +divergence AGAINST — and that if the line is read on `main` the set is invalid. + +**And the trailer had to be re-pushed**, which is a second edge of the same mechanism: the +`witness-generation` job reads `Witness-regen:` from the HEAD commit message, and the intermediate +commit displaced the one that carried it. A trailer on an ancestor is not read. + +**THAT RESIDUAL WAS RETRACTED THE SAME DAY, and the retraction is the useful part.** I recorded the +hook and the remote regeneration as MUTUALLY BLOCKING, needing three pushes. **They are not.** P1-2 +made local generation work on a structural change — `rc=0`, measured — so the correct flow is: change +the scene, regenerate LOCALLY, and commit the new baseline IN THE SAME COMMIT. The hook then passes and +one push suffices. My three pushes came from choosing to wait for CI to produce what I could produce +here, not from a blocking mechanism. Claiming a residual for a self-inflicted detour would have left a +false obstacle in the record for the next milestone to route around. + +What DOES remain, smaller and real: the `witness-generation` job reads `Witness-regen:` from the HEAD +commit message, so any commit interposed after the one carrying it puts the trailer out of reach. That +is independent of P1-2. Owner: whoever next opens the regeneration flow. + +### 2026-08-18 — INTER-ISA BIT-IDENTITY IS MEASURED, AND `chain_applies` NOW MEASURES INSTEAD OF SKIPPING + +**The eight committed witnesses are BIT-IDENTICAL between `ubuntu-24.04` (x86_64) and aarch64-macOS**, +the four 1000-frame chains included, compared file by file against the CI artifact. + +**My two alternative readings are both refuted by measurements already in hand, and Guy supplied the +refutations.** «The control does not judge» — refuted by P1-4's own counter-factual: a stale witness +gives `rc=1`, so the chain comparison discriminates. «Trivial agreement on exactly-representable +values» — refuted by the slider residual: `5.000002` at f32 is FOUR ULP of ACCUMULATED rounding, which +a trajectory staying on exact values could not produce. This milestone already carries the proof that +the scene rounds. + +**So it is the first reading, and the mechanism PREDICTS it.** IEEE-754 specifies the correctly-rounded +result of `+ - * /`, `sqrt` and comparisons, so two conforming implementations on the same inputs IN +THE SAME ORDER give the same bits. The inter-ISA divergence sources are reassociation, contraction, +transcendentals, denormals, rounding mode and extended precision — and this milestone removed every +one: explicit left folds, no FMA, an in-tree cosine pinned to a bit table, FTZ/DAZ off, the rounding +mode installed, `-Dcpu=baseline`. C1.1 filed level 3 out of Phase 1 on the assumption that it would +require a whole libm; that assumption was written for code using transcendentals and `@reduce`, and the +code no longer does. + +**LEVEL 3 IS NOT PROMISED AND C1.1 DOES NOT MOVE.** One measurement, one scenario, one machine pair of +which one is not even in the matrix. Making it a contract would bind every future shape and solver +change, while the property holds by the ABSENCE of a single transcendental on the path — the day a shape +introduces one it would break and be disabled, and a guard disabled at its first failure is not a guard. + +**But the skip had to go.** It threw away the strongest available signal. The chain is now compared on +every host and REPORTED where level 1 does not apply, exactly like the divergence frame: its REGRESSION +is the signal, never its value. No pass/fail added, therefore no promise added — and the day the +agreement ends, it is learned instead of being a surprise. Output on aarch64: +`chain verdict : OK (1000 frames) (REPORTED, not gated — level 1 is intra-ISA)`. + +**And correcting it left the superseded comment in place for one edit** — the five lines declaring the +skip sat directly above the new block until a re-read removed them. The exact motif recorded two +sections above, reproduced while writing the correction to something else. + +### 2026-08-18 — SECOND REVIEW ROUND: A GUARD GATED BY WHAT IT PROTECTS, AND TWO PARALLEL WITNESSES + +**P1-1 — the sensor test was GATED ON `current`, so it could not see what it protected.** The +comparison ran only where `current` was non-empty: at entry that masks a missing `entered`, and the +frame where only `exited` is populated is never observed at all. Removing BOTH deltas from `dumpState` +left it green. «A counter-factual has a correct INSTANT» was the lesson I had just written for the +lockstep evaluated at the last frame, and my fix applied it to `current` and to neither of the others. + +Now THREE independent discriminations, one per set, each evaluated at a frame where THAT set is +non-empty. And the suppression is written as an EMPTY SET, never as an omission: omitting drops the +length prefix too, so the bytes would move even at a frame where the set is legitimately empty, making +each discrimination green for a reason unrelated to its frame. Three counter-factuals, three separate +removals, three reds — and the failure message names the count: `current` non-empty on 25 frames, +`entered` on 1, `exited` on 1. A single-frame delta is exactly what the gated version could not see. + +**P1-2 — two true witnesses side by side with nothing joining them.** `saw_cross_pair` on one hand, +`fell`/`rose` on the GLOBAL island count on the other. The sleeper already supplies a fall, any +transition anywhere can supply a rise, and nothing tied either to `group_a`/`group_b` — the test passed +even if the groups stayed merged. Same class as a trace agreeing with itself: a conjunction of facts is +not the fact sought. **And the FORM was Guy's instruction**, which said to add a targeted assertion +BESIDE the vague one; juxtaposing them strengthens nothing when nothing conjoins them. + +Replaced, not juxtaposed: island MEMBERSHIP of the two groups is followed directly and the sequence +SEPARATE → ONE ISLAND → SEPARATE is required on those bodies. Measured at the shipped tuning: apart +from frame 0, merged at 15, apart again at 21. `max_islands > min_islands` survives only in the coverage +test, where it answers a different question. + +**AND MY FIRST COUNTER-FACTUAL FOR IT WAS NOT DISCRIMINATING, reported rather than kept.** Restitution +0 still reaches phase 3 — with friction zero they part regardless — so it proved nothing about the +clause. Replaced by a window truncated to 20 frames, before the split at 21: it fires and names the +phase reached, `2 of 3`, which is precisely the merge-only scene the clause exists to refuse. + +**P2-4 — the numbering was unstable**, 1 to 8 with a `6 bis` while the text cited a nonexistent +element 9 and the main test still said eight. Nine entries, 1 to 9, no bis, and the three other sites +aligned. A frozen scope cannot be verified against an unstable numbering. `CLAUDE.md` also carried the +old floor `1866/1864` against `1869/1867`. + +**P2-3 — the provenance said `sha=pending` and local ARM origin** while run 32096367333 on the HEAD had +accepted exactly those eight files on twelve cells. Rewritten with `cell=ubuntu-24.04` as the +authoritative origin, the verifying run named, and **the dependency kept explicit**: local generation +produces the cell's bytes only while inter-ISA bit-identity holds, which rests on the absence of a +transcendental on the deterministic path — the day a shape adds one, a cell reddens and names the frame. + +### 2026-08-18 — THIRD ROUND: THE COUNTER-FACTUAL TESTED THE FUNCTION, NOT THE PATH + +**P1 — one question, three formulations, one element.** The sensor reached no artifact; then the test +was gated on `current`; now the fix tested `dumpSensorSets` AGAINST ITSELF. `full` was produced in the +loop and entered no comparison, and the single integration check ran after the 60 frames, where all +three sets are empty — so it compared emptiness. Making `dumpState` emit only `current` left the whole +Forge suite green. + +What was proved: the function can serialise three sets. What had to be proved: the three arrive in the +COMPARED artifact. The assertion is now on the SUFFIX of what `dumpState` produces, evaluated at a frame +where the set in question is non-empty. + +**AND THE CORRECTION HAD A TRAP OF ITS OWN, avoided by construction: the reference uses a LITERAL mask +and not `all_sensor_sets`.** With the reference reading the same constant production reads, a +counter-factual on production moves both sides and the test fires for the wrong reason — which is how +the previous round's three counter-factuals passed while never touching `dumpState`. Three +counter-factuals on PRODUCTION now, and each reports `in dumpState=false` while `contributes=true`, +which is the separation that shows the loss is in the path and not in the record. + +**GUY'S QUESTION APPLIED TO THE NINE, and the answer is not uniform.** «Does the counter-factual bear +on real production?» Only two of the nine reach an artifact through a SERIALISATION FUNCTION — the +sensor and the character — and those are exactly the two where a test could confront a function instead +of the path. Both are now asserted on `dumpState` output. The other seven are read DIRECTLY off world +state — `world.constraints`, `world.islands`, `world.active`, `bm.linearVelocity` — where no +intermediate exists to be mistaken for the path. That asymmetry is the general form of the finding: +**the risk lives wherever an artifact is reached through a helper**, and nowhere else. + +**P2 — three stale live contracts, all superseded by their own fixes.** The tuning comment still +described the ORIGINAL scene — 4 m/s closing over a 12 m gap, contact near frame 180 — after a retune +that moved every figure; it now carries the current ones and the measured phases 0/15/21. The +renumbering was swept rather than patched at the reported instances: `trace.zig`'s «element 7 of 7» and +its «twelve mobile bodies» were both still there. And the provenance was hand-written with `sha=pending` +and a local ARM origin while the run's own artifact carried the canonical one — TAKEN from the artifact +now, not rewritten, with the local-generation dependency appended as an explicitly conditional annex. + +The eight committed files are identical to the run artifact's, 8 of 8, which is the third independent +confirmation that local generation produces the cell's bytes. + +### 2026-08-18 — FOURTH ROUND, comment only: a claim the predicate does not carry, and the numbering again + +**P2-1 — the coverage test's own comment attributed `max_islands > min_islands` to element 3 and +claimed BOTH directions.** That predicate carries neither: `max > min` is satisfied by ONE variation in +either sense, and the lone sleeper leaving the partition supplies exactly that by itself. The targeted +membership test is correct and does not make the sentence true — a correct assertion elsewhere does not +retroactively justify a false claim here. + +Attribution removed. The predicate stays as an unattributed coverage probe — the partition moves at all +— and the two-direction property with its attribution to the two groups lives only in the test that +follows island MEMBERSHIP. **The form was Guy's instruction**, «add yours beside it», which is what +produced two parallel witnesses: the targeted one carries the sequence, the global one carries nothing. + +**P2-2 — the renumbering, third round.** Three ACTIVE comments still called the terrain element 7 while +the numbered blocks made it 8. Swept as a class rather than at the three reported lines, and verified in +BOTH directions afterwards: the nine numbered blocks read 1 to 9 in order, and the only textual indices +left are 5 and 8, both correct. Three rounds on one renumbering is the measurement — a numbering changed +in one pass and referenced in prose from four files is not swept by fixing what a reviewer happens to +cite. + +## Recorded deviations + +- **The assembly inventory is a dedicated build step, not a case inside `zig build test`.** The + brief lists it under Tests. Realized as `zig build forge-asm-inventory` because it cross-compiles + the whole physics module three times (~12 s wall, ~35 s CPU), `zig build test` is run TWICE on + every `git push` by the pre-push hook, and the answer is a property of the three targets rather + than of the host asking — so one CI cell covers the matrix. The in-repo precedent is + `verify-synth-100`, wired exactly this way for the same reason. The scanner's own five + counter-factuals DO ride in `zig build test`: a scanner that cannot fire would report a clean + tree for the wrong reason. + +- **The PR is opened as a DRAFT after Gate A, not at Étape 5.** Guy's arbitration, recorded here + as a Cas 3 because it changes WHEN validation arrives for each gate rather than how a tool is + run. `ci.yml` triggers only on `push` to `main` and `pull_request` to `main`, so a feature-branch + push runs nothing: without an open PR, the Gate A exit "the environment assertion is green on the + three targets" is not producible, and Gates C and D — "chain identical between Linux and Windows" + and "the `ubuntu-24.04-arm` cell" — are structurally unmeasurable. Étape 5 of the prompt template + becomes "mark ready for review". Two owners for the rule itself, and the patch travels with the + others at Gate E: `engine-development-workflow.md` for the workflow rule, and the prompt template + for the gate-by-gate clause its invariant section does not carry (that one is outside this + milestone's file list and has its own pass). + **Scope caveat, stated because the review named it:** the "green natively" evidence produced so + far is macOS/aarch64 — a FOURTH platform, absent from the matrix. Same ISA as the arm64 Linux + cell, different libc and ABI. An indication, not the measurement. + +### 2026-08-15 — RECORDED DEVIATIONS, consolidated + +Four, of which three are additions to the frozen Scope, each arbitrated by Claude.ai: + +1. **Explicit left fold replacing float `@reduce`, at 18 sites.** Third named behavioural change. + The Scope list of two was not exceeded but WRONG: it excluded `@reduce` on a recon measurement + that proved partial. +2. **Use-after-return in the render graph, fixed (branch A).** `buildPass` returned a `Pass` whose + slices pointed into its own stack frame; the storage moves into the `Config`, which was already + the pass's `ctx` and therefore already outlived it. `capture.zig` carried the same defect with a + test that passed because it never checked content. +3. **The dead-test guard**, `tools/weld_lint/dead_tests.zig`, delivered INACTIVE. +4. **Two frozen brief notes struck in place with their refutation**: "the marker falls because the + sequence is ordered" — ordered was never the property — and "`@reduce(.Add)` has exactly four + sites" — true for `.Add`, blind to `.Min`/`.Max`, 18 float sites of 44. + +### 2026-08-16 — RECORDED DEVIATION 5: `src/core/ipc/shm.zig` and its two backends + +**Fourth addition to the frozen Scope, arbitrated by Claude.ai.** Eight sites across +`ipc/shm.zig`, `ipc/shm_posix.zig` and `ipc/shm_windows.zig` move from +`align(std.heap.pageSize())` to `align(std.heap.page_size_min)`. + +**Why it is not a scope violation despite `shm.zig` being outside the file list and Tier 0 being +complete since M0.7.** AArch64 is an OFFICIAL target — `engine-physics-forge.md` §1.5 names it — and +the repository did not compile for a target it declares it supports. The milestone that compiles it +for the first time is the one that discovers that, and fix-as-you-go admits no exception. The failure +mode also makes the frozen surface safe here: WEAKENING an alignment claim cannot break a consumer in +silence, because either it still compiles or it does not, and that is visible. A frozen surface +guards against changes of BEHAVIOUR, and this is not one. + +**THE BOUND IS THE MINIMUM, AND THE OTHER ONE WOULD HAVE BEEN FALSE.** Not merely wasteful: +`page_size_max` declares `align(65536)` on aarch64-linux for memory `mmap` only guarantees to 4096, +which is illegal behaviour caught at runtime in Debug and ReleaseSafe. A pointer aligned to the real +page is always aligned to the minimum, so the minimum is TRUE on every target. One claim is given up; +no false one is taken on. + +**Both preconditions measured before the edit, neither asserted.** + +| target | `page_size_min` | `page_size_max` | `@alignOf(Header)` | +|---|---|---|---| +| x86_64-linux | 4096 | 4096 | 8 | +| aarch64-linux | **4096** | **65536** | 8 | +| x86_64-windows | 4096 | 4096 | 8 | +| aarch64-macos | 16384 | 16384 | 8 | + +`page_size_min` exists and is comptime on all four. And the alignment is **NOT LOAD-BEARING**, which +is a measurement and not a reassurance — the phrase was named this milestone as a claim that requires +one: every consumer reaches the pointer through an `@alignCast` to `*Header` at `viewport.zig` 143, +197 and 210, and `@alignOf(Header)` is **8** on every target, three orders below the smallest +`page_size_min`. Values obtained per target by `@compileError` at comptime, the technique +`engine-development-workflow.md` §4.8 records for a comptime assert: the compile error IS the +measurement. + +On x86_64 and Windows the change is a strict no-op — `min == max == 4096` and `pageSize()` already +folded there. Only aarch64-linux moves, from uncompilable to a declaration that is true. + +**Result: `zig build -Dtarget=aarch64-linux-gnu -Dcpu=baseline` returns 0, and the test graph goes +from 22 compile failures / 121 successes to 0 / 132.** + +### 2026-08-16 — RECORDED DEVIATION 6: an import placement in `build.zig` + +Wiring `foundation` into `render_module`, `demo_module` and `demo_codegen_module` for P1-3 hit a +declaration-order problem: `foundation_module` is declared AFTER `render_module`, so the first +attempt failed with `build.zig:85:43: error: use of undeclared identifier 'foundation_module'`. + +**The import moved to the first point where the module is in scope, rather than the declarations +being reordered.** Smaller surface, and the graph's declaration order is normative nowhere. Recorded +because it touches the shape of the build graph and a reader of the diff would otherwise have to +reconstruct why one import sits apart from its siblings. + +The dependency itself carries no coupling risk: `foundation/math/` is what C1.1's own metric leaves +to `forge_3d` alongside `forge/api`, so a module taking it crosses no boundary another does not +already cross. + + +## Blockers encountered + +- **The PR is opened as a DRAFT after Gate A, not at Étape 5.** Guy's arbitration, recorded here + as a Cas 3 because it changes WHEN validation arrives for each gate rather than how a tool is + run. `ci.yml` triggers only on `push` to `main` and `pull_request` to `main`, so a feature-branch + push runs nothing: without an open PR, the Gate A exit "the environment assertion is green on the + three targets" is not producible, and Gates C and D — "chain identical between Linux and Windows" + and "the `ubuntu-24.04-arm` cell" — are structurally unmeasurable. Étape 5 of the prompt template + becomes "mark ready for review". Two owners for the rule itself, and the patch travels with the + others at Gate E: `engine-development-workflow.md` for the workflow rule, and the prompt template + for the gate-by-gate clause its invariant section does not carry (that one is outside this + milestone's file list and has its own pass). + **Scope caveat, stated because the review named it:** the "green natively" evidence produced so + far is macOS/aarch64 — a FOURTH platform, absent from the matrix. Same ISA as the arm64 Linux + cell, different libc and ABI. An indication, not the measurement. + +## Blockers encountered + + +- **2026-08-14 14:25 — GATE C BLOCKED: `workflow_dispatch` cannot fire from a feature branch, so + the arbitrated provenance mechanism does not work pre-merge.** Measured, not assumed: + `gh workflow run determinism-witnesses.yml --ref phase-1/forge/determinism` returns + `HTTP 404: workflow determinism-witnesses.yml not found on the default branch`. GitHub requires a + `workflow_dispatch` workflow to exist on the DEFAULT branch before it can be dispatched at all, + whatever `--ref` says. The workflow file is written, pushed and correct — it simply cannot be + triggered until it is on `main`, which is after Gate F. + + **Corroboration in-repo, and it makes the same claim wrongly.** `.github/workflows/nightly-fuzz.yml` + lines 7-8 state that "`workflow_dispatch` lets it be triggered manually from the Actions tab in + the meantime", i.e. before its branch is merged. This measurement refutes that sentence. It has + been dormant since M0.7 because nobody tried it from a branch. + + The generation path itself is verified and is NOT the blocker: locally, + `zig build forge-determinism -- --write-witness DIR` produces the three files with the frozen + names and the right sizes, and the workflow's cross-mode agreement check is exercised — on + aarch64-macos Debug and ReleaseSafe agree byte for byte on all three kinds, which is an indication + for the brief's Debug ≡ ReleaseSafe hypothesis on a FOURTH platform and therefore not the + measurement. + + What is blocked is only WHERE the witnesses are produced, and it is a design decision rather than + an implementation choice, so it is not mine to take: the options change the provenance story. + Merging early contradicts the gate protocol and the one-milestone-one-commit convention; + generating from a job inside `ci.yml` on the PR branch works pre-merge and can keep the "declared + act" property if it is gated on an explicit marker rather than running on every push; a + `push`-triggered workflow on the branch would work but would make generation an automatic SIDE + EFFECT, which the arbitration forbids by name. + + Frozen until Claude.ai rules. Nothing is committed to the witness directory. + +- **2026-08-16 — GATE D BLOCKED: the `ubuntu-24.04-arm` cell dies at `zig build`, on a PRE-EXISTING + defect located to one line, and clearing it is a Tier 0 decision that is not mine.** + + Run `https://github.com/weldengine/weld/actions/runs/31937352197`, commit `571814b`. Both ARM cells + FAIL, and the failing step is `zig build` — before any test, before the determinism harness. So + **this is not the discrete parity**, and Gate D's pass/fail has not been reached, let alone missed. + + **Reproduced locally and attributed, not inferred.** `zig build -Dtarget=aarch64-linux-gnu + -Dcpu=baseline` gives the same error here; and `main` at `5259344`, measured through a worktree on + this same toolchain today, fails IDENTICALLY. Pre-existing, and not caused by this branch. + + **The cause is one line of our tree**, with the full chain in the compiler's own note stack: + + ``` + src/core/ipc/shm.zig:68: ptr: [*]align(std.heap.pageSize()) u8, + -> std.heap.pageSize() -> std.options.queryPageSize() + -> global.cached_result.load(.unordered) -> @atomicLoad + "operation is runtime due to this operand" + "struct field types must be comptime-known" + referenced by ipc.viewport.ShmViewport, fromFd + ``` + + A struct field's alignment must be comptime-known. On x86_64 and on Windows the page size is fixed, + so `pageSize()` folds at comptime and the field compiles; **aarch64-linux has a variable page size** + (4K/16K/64K), so the call routes through a runtime atomic load and the field cannot be typed. The + cell has never compiled there, and nothing before this milestone asked it to. + + **The blast radius is measured, and it is small and precise: 22 compile steps fail, 121 succeed** + for `aarch64-linux-gnu`, every failure tracing to that one line through `weld_core`'s IPC. In + particular **`compile exe forge-determinism Debug aarch64-linux-gnu` SUCCEEDS** — the Gate D + instrument itself builds for the target; it merely cannot RUN on this macOS host, which is a + property of the host and not of the cell. + + **Why this is a STOP rather than a fix.** `src/core/ipc/shm.zig` is Tier 0 IPC, complete since M0.7, + and it is outside this brief's file list. The repair is a semantic choice, not a rename: a + comptime-known bound such as `std.heap.page_size_max` compiles everywhere but OVER-ALIGNS the field + wherever the real page is smaller. And the alternative — scoping the ARM cell to the steps Gate D + needs — is not a neutral act either: `engine-platform.md` §8 describes the full + `{os} × {mode} × {f32, f64}` product, so a partial ARM cell is the same class of question as the + f64 axis, where the ruling was already "either it is built or §8 is amended". A cell inside + `ci-gate` claims what its name suggests unless what it skips is named. + + Both routes were measured rather than argued; neither is taken. Nothing is committed toward either. + Frozen until Claude.ai rules. + +### 2026-08-16 — GATE D, MEASURED. AND `lint` ON CI WITH A CELL-LEVEL COUNTER-FACTUAL + +**LEVEL 2 POINT 1 IS GREEN ON THE REAL ARM64 CELL — the half of C1.1 no machine in rotation could +measure.** Run `https://github.com/weldengine/weld/actions/runs/31940222830`, commit `9a31746`, all +ten jobs green including both `ubuntu-24.04-arm` cells. Read from the cell logs, four combinations +(Debug and ReleaseSafe × f32 and f64), identical on all four: + +``` +self-reproducible : OK +chain verdict : SKIPPED (level 1 is intra-ISA; this host is aarch64) +trace verdict : island partition OK +trace verdict : sleep state OK +trace verdict : per-pair manifold cardinality OK +trace verdict : retained pair set OK +divergence frame : none within K=60 +``` + +The four discrete invariants are identical to the x86_64 witness over 60 frames at both precisions. +The chain comparison skips WITH ITS REASON PRINTED, which is the design and not an omission. And the +encoding check carries to this ISA too: 40320 B of poses at f64 against 20160 at f32, exactly double. + +**`none within K=60` on the genuine cell, and the bounds are the ones already written**, restated +because this is where they will be read: it bounds a CONTINUOUS deviation at `1e-4 × body radius`, +over 60 frames of THIS scene, and it establishes nothing about level 3, which C1.1 puts out of +Phase 1. A continuous divergence here would not have been a failure — level 2 predicts one. Its +REGRESSION is the signal, never its value. + +**`zig build lint` NOW RUNS IN CI**, on `ubuntu-24.04 / Debug`, and its output shows both guards +executing there for the first time: `dead-tests: control — closure 1863 - 5 declared uncollected on +linux = 1858 expected collected`, then `clean`. The Linux suite total measured on the same run is +1858 — two independent computations, one number, on a platform that is not mine. + +**THE COUNTER-FACTUAL IS AT CELL LEVEL, and reaching it required routing around two closures.** A +committed violation is impossible: the `pre-commit` hook lints the whole tree and `--no-verify` is a +standing prohibition. The `tests/lint/bad` corpus is no route either — MEASURED, `scan.collectZigFiles` +skips that prefix even when it is passed explicitly, so linting it returns 0. So a temporary step +planted the violation ON THE RUNNER, where git never sees it, after local verification in both +directions. + +Result, run `31942284031`, commit `a18b408`: `build-and-test (ubuntu-24.04, Debug)` **failure**, +**`ci-gate` failure**, and exactly one cell of six red. The chain is complete in the log — +`--- probe planted; the next command MUST fail ---`, then +`src/foundation/lint_counterfactual_probe.zig:2:1: doc_comments: missing '///' doc comment on public +declaration`, then `Process completed with exit code 1`. The step's own +`::error::the lint step did NOT fail on a planted violation` guard did NOT fire, which is what +separates "lint failed" from "the step's fallback fired" — a distinction this milestone has paid for +before, where a compile error and an assertion failure shared an exit code. Step removed in the +commit that follows. + +**THE WARM FIGURE IS NOT AVAILABLE FROM THIS RUN, and saying so is the point.** `windows-2025 / +ReleaseSafe` reports `build_seconds=472` against 17 on the previous run — not a cache failure but a +source change: `shm.zig` sits at the heart of `weld_core`, so nearly everything downstream recompiled. +A run measuring a core edit is not a warm-cache measurement, and the number that decides the f64 axis +still has to come from a run whose sources are unchanged against the restored archive. + +### 2026-08-16 — THE WARM FIGURE, and what it says about the f64 axis + +Run `https://github.com/weldengine/weld/actions/runs/31942995917`, commit `c3d6073` — the +counter-factual step removed, ALL ELEVEN JOBS GREEN, so the cell-level probe is bilateral: red with +the planted violation, green without. This commit touches only `ci.yml` and this brief, nothing under +`src/`, which is what finally makes a warm reading possible. + +| cell | `build_seconds` | `test_seconds` | wall | budget | +|---|---|---|---|---| +| `ubuntu-24.04 / ReleaseSafe` | **2** | 70 | 2 min | 55 | +| `windows-2025 / ReleaseSafe` | **2** | 224 | 5 min | 55 | +| `ubuntu-24.04-arm / ReleaseSafe` | 19 | 19 | 1 min | 55 | +| `ubuntu-24.04 / Debug` | 33 | 112 | 3 min | 20 | +| `ubuntu-24.04-arm / Debug` | 95 | 321 | 7 min | 20 | +| **`windows-2025 / Debug`** | 302 | 521 | **14 min** | **20** | + +**The cache fix did what it was for**: two seconds of build on both x86_64 ReleaseSafe cells, against +472 on the run that edited `shm.zig` and 17 on the frozen archive before it. + +**AND THE CONSTRAINT THAT DECIDES THE f64 AXIS IS NOT THE ONE THE CAP WORK WAS AIMED AT.** The +ReleaseSafe cells sit at 1-5 minutes of a 55-minute budget; the binding cell is `windows-2025 / Debug` +at **14 minutes of 20**, `cache_enabled=false` by an earlier deliberate decision. A `{f32, f64}` axis +takes the matrix to twelve cells: the six ReleaseSafe ones are nearly free, and what doubles is the +cold Debug cell already at 70 % of its budget. + +**The ARM cells are the CHEAPEST in the matrix** — 1 minute in ReleaseSafe, 7 in Debug — which is +worth recording against the assumption that a new architecture is expensive. + +### 2026-08-16 — GATE E: THE SLIDER RESIDUAL, QUALIFIED + +**The brief's acceptance criterion nobody had touched all session.** It imposed an alternative with +no third branch: either the M1.1.13.1 residual persists after the float environment is pinned — and +is then characterised against the solver's own arithmetic — or it disappears, and its cause WAS the +unpinned environment, this milestone's own subject. + +**IT PERSISTS, and the f32 figure has not moved by one bit.** Measured after the libm removal, the +explicit folds, the installed FPU and `-Dcpu=baseline`: + +| precision | retained | bits | excess | +|---|---|---|---| +| f32 | `5.000002` | `0x40A00004` | **4 ULP** | +| f64 | `5.000000000` | `0x4014000000000003` | **3 ULP** | + +`5.000002` is exactly what M1.1.13.1 recorded. So the first branch is taken and the answer to the +alternative is negative: the unpinned environment was NOT the cause. + +**AND THE f64 FIGURE REFUTES THE ARGUMENT THAT USED TO SETTLE THIS.** M1.1.12 measured exactly `5.0` +at f64 and reasoned "a solver adding energy would add it at both precisions", concluding the effect +was arithmetic BECAUSE f64 showed none. It shows 3 ULP now. That argument is dead, and the entry in +`CLAUDE.md` that carries it is wrong as written. + +**The conclusion survives on a stronger discriminant, and the margin is nine orders — but the number +was wrong the first time and its correction is the tenth instance of this milestone's shape.** +Energy injected at a PHYSICAL rate is precision-independent in relative terms, so reproducing the f32 +excess at f64 means reproducing the same ABSOLUTE excess at the same value. 5.0 lies in `[4, 8)`, so +`ULP_f32(5.0) = 2^(2−23) = 2^−21` and `ULP_f64(5.0) = 2^(2−52) = 2^−50`, a ratio of `2^29`. The f32 +excess is therefore `4 ULP_f32 = 4 × 2^29 ULP_f64 = **2^31 ULP_f64**`, against **three** measured. + +Stated as powers of two on purpose: it is checkable by exponent arithmetic without a machine. **The +first version of this figure read `1 717 988 150` and was wrong by exactly 5/4**, because it was +`relative excess / eps` — ULP at **1.0** — where the question is ULP at **5.0**. A quantity computed +on one base and reported on another, the same shape as collected-versus-source, +local-versus-cell, and `live_tests`-versus-collected-total earlier in this milestone. A ten-digit +decimal in a permanent pin is unverifiable, which is how it survived being written. + +**THE f64 RESIDUAL'S CAUSE IS NOT ATTRIBUTED.** M1.1.12 measured exactly 5.0; three ULP appear today. +Two changes sit between those measurements — the TGS Soft port at M1.1.13.1, and this milestone's +explicit left folds, which alter the summation order of `dot` and `lengthSq` and are of exactly this +magnitude. Neither has been measured against this scene, so neither is named. The conclusion does not +depend on it, and the corpus will say the residual appeared between M1.1.12 and today without +claiming why. + +**Pinned, and the bound is in ULP rather than in metres on purpose.** A metre bound would pass at f64 +for an energy injection nine orders too large, so it would be an assertion that cannot fail where it +matters most. The test carries its positive witness first — there IS an excess, without which the +bound is satisfied by a slider that lost speed — and its counter-factual on the object was run: the +bound tightened to 0 ULP fails, naming the test, `compilation errors` = 0. `forge_3d` 547 → 548. + +### 2026-08-16 — GATE E: THE CACHE DECISION RE-MEASURED, AND A GUARD THAT WAS HALF-APPLIED + +**The ReleaseSafe-only cache restriction rested on a measurement this milestone has refuted**, so it +is lifted rather than inherited. It was adopted when `Save Zig cache` went 39s → 5m23 → 7m39 and ate +the Debug budget; both causes of that inflation are now fixed — the key carried no CPU axis, so every +run mixed two CPU variants into one archive, and the oversized result met a cap that purged it. + +**AND THE ALL-OR-NOTHING GUARD WAS NEVER ALL-OR-NOTHING — one of the two save steps bypassed it.** +`Save Zig cache (final)` carried `always() && matrix.mode == 'ReleaseSafe'` and never consulted +`steps.cache-size.outputs.save`. Measured on run `31932309771`: the pre-check warned at +`3 094 724 608 bytes … SKIPPING the save`, `Save Zig cache (post-build)` was correctly skipped, and +`Save Zig cache (final)` reported `Cache saved with key: …` twenty seconds later. + +**Which also corrects MY OWN framing from earlier today.** I wrote that the archive was "frozen at an +older sha, the restore working and only the save skipped". It was not frozen: it was being refreshed +every run by the step that bypassed the guard. Third correction in the cache story, and this one is +mine rather than inherited. + +The final save now takes its OWN size measurement instead of reusing the earlier output, because +`always()` there is deliberate — the cache is worth saving even when tests fail — and the earlier +step is skipped on a failed build, which would have turned `always()` into `never` on exactly those +runs. + +**THE RE-MEASUREMENT, run `31948580304` on `4290b31`: all eleven jobs green, and the restriction was +mis-founded on BOTH of its premises.** + +Every Debug cell now restores and saves, and both save steps carry a size measurement — the +contradiction of the previous run, `SKIPPING the save` followed twenty seconds later by +`Cache saved with key`, is gone. + +`windows-2025 / Debug` came in at **13 minutes against its 20-minute budget** — `cache_matched_key=none`, +so FULLY COLD, `build_seconds=239`, `test_seconds=512` — and that is one minute FASTER than the 14 it +took with no cache at all, save cost included. The save it was denied costs less than nothing. + +And the second premise falls on size: its archive measures **593 604 608 bytes**, 0.59 GB, against +2.86 GB for its ReleaseSafe sibling. The cell the restriction protected is the one with the SMALLEST +archive in the matrix. + +The warm figure still needs one more run: this one changed `mesh_test.zig`, so `forge_3d` rebuilt +everywhere, and the Debug cells had never held an archive under the new key. + +### 2026-08-16 — GATE E: THE AXIS BUILT, AND A CHECK THAT HAD STOPPED RUNNING + +**THE ×23 ON `ubuntu-24.04-arm / ReleaseSafe` IS LOCATED, and it is compilation.** Bounded to two +runs and a diff of their `--summary all`, as arbitrated: + +| run | `compile` steps that RAN | reused from cache | compile wall | +|---|---|---|---| +| `31948580304` | **1** | 131 | 57 s | +| `31949738121` | **129** | 3 | **2392 s** | + +The 1341 extra seconds are `compile test` steps, not test execution. **Compilation does not depend on +the precision** — `-Dphysics_f64` moves a comptime constant, not a volume of code — so this is the +first of the three outcomes: the f64 twin costs the same, the axis builds as-is, budgets unchanged. +And 25 minutes is a CACHE-MISS figure rather than a steady state: the same cell ran in 1 minute when +131 of 132 binaries were served from cache. + +**What collapsed the reuse is NOT established, and the obvious candidate is excluded.** Measured +locally: editing `mesh_test.zig` and rebuilding causes **3** compile steps to run, not 129. So the +edit between the two runs does not explain it. Named for the plan with its figures, unattributed. + +**THE AXIS IS BUILT ON THE WHOLE MATRIX — twelve cells.** `engine-platform.md` §8's third dimension, +constructed rather than amended away, because a supported build flag no cell exercises is an untested +configuration. The cache key carries `f64_`: without it the two halves of the axis would +thrash one archive, which is the mixed-CPU defect in a new guise. The determinism step stops looping +over both precisions inside a cell and takes the axis's instead. + +**AND RESTORING THAT STEP EXPOSED THE TWELFTH INSTANCE OF THIS MILESTONE'S SHAPE — MINE, AGAIN.** +`zig build forge-determinism` had DISAPPEARED from every cell. The commit that removed the temporary +`lint` counter-factual cut a span running from that step's comment to the timing report's, and the +determinism step sat between them. Measured rather than inferred: +`git show :.github/workflows/ci.yml | grep -c "name: zig build forge-determinism"` returns **1** +at `9a31746` and `a18b408`, and **0** at `c3d6073` and after. + +**So three runs went green with no determinism check at all** — `31942995917`, `31948580304`, +`31949738121` — because a step that does not run cannot fail. Gate D's measurement stands: it was +taken on run `31940222830` at `9a31746`, before the deletion. But every "full matrix green" I +reported after that point was green over a smaller object than I said, and the correction belongs +here rather than in a footnote. + +The irony is exact and worth keeping: the deletion was performed by the commit removing a +counter-factual whose entire purpose was to prove that a check must ACTUALLY RUN. + +**TWELVE CELLS GREEN, run `https://github.com/weldengine/weld/actions/runs/31952499292` on +`a5ee88e`** — the first run in the repository's history to exercise all three axes, and the first +since `9a31746` to actually execute the determinism harness. + +**Verified cell by cell rather than inferred from green**, which is precisely what the vanished step +had just cost: `zig build forge-determinism` is PRESENT and `success` on **12 of 12**, counted by +enumerating each job's steps rather than reading its conclusion. The verdicts: + +- the four **f64 x86_64** cells — `chain verdict: OK (1000 frames)`, four traces OK, divergence none; +- the two **f64 ARM64** cells — `chain verdict: SKIPPED (level 1 is intra-ISA; this host is + aarch64)`, four traces OK, divergence none. + +Each prints `precision=f64`, which is what proves the axis THREADED THE FLAG rather than merely +labelling the matrix — the same distinction the deleted step had just made expensive. + +**So `-Dphysics_f64` is exercised in CI for the first time.** Until this run every f64 claim in this +milestone and its predecessors rested on one machine, one architecture, one compiler — the asymmetry +`CLAUDE.md` has carried as an open decision since M1.1.12. It now runs on six cells across three +operating systems and two architectures, and `engine-platform.md` §8 describes what the CI does with +no amendment needed. + +### 2026-08-15 — THE CLOSED LIST, items 1-8, with the exact state of each + +**THE LIST IS CLOSED.** Any defect found outside it is measured, recorded, and NOT treated: it +leaves as a named entry for the plan. + +| # | item | state | +|---|---|---| +| 1 | Float `@reduce` → explicit fold, 18 sites | **DONE.** Lint rule `no_float_reduce` active; LLVM output bit-identical (12/12 witnesses, 4 corners); self-hosted corner proven fixed by disassembly. | +| 2 | Upstream Zig report | **WRITTEN, NOT FILED.** `briefs/artifacts/m1.1.14-zig-reduce-order-issue.md`, against master, citation levelled onto master, `.strict` identity established. Filing is Guy's act. | +| 3 | Dead-test sweep | **DONE.** 1871 source blocks vs 1772 collected; render (45), zig_codegen (37), bindgen (8), exact.zig (2), audio (1), shm_posix (1) named; +12 repaired and green. | +| 4 | Render use-after-return | **DONE**, branch A, pinned structurally by pointer provenance, counter-factual verified. | +| 5 | Dead-test mechanical guard | **INACTIVE.** 23 fixtures green including the two hostile pairs. Blocked on ONE unresolved fact: a second incoming edge into `src/etch/zig_codegen/`, internal to `src/etch/`, not identified — the module-boundary references found are NOT what the closure follows. **The edge crosses a MODULE boundary** — the two live targets reach `codegen_zig` through `weld_etch` — while this closure follows RELATIVE imports only, so it measures one level and renders a verdict about another: the milestone's recurring scale mismatch, one last time. **Not to be hunted by hand**: the per-step measurement names it mechanically, and does not depend on guessing where to look. Activation is a Gate F exit condition. | +| 6 | Closure control | **INSTRUMENTED WRONG, and that is the finding.** `live_tests` counts blocks of every file in the closure; the suite total counts blocks COLLECTED. Two different quantities. It needs the per-cell collected total that `--summary all` produces from the next run — which does not exist yet anywhere. | +| 7 | Windows cache | **DONE.** `ZIG_CPU` in all ten keys; all-or-nothing save with a `::warning::` naming the mechanism. Cold runs expected and NOT a regression. | +| 8 | Cross-mode re-measure, generation, six witnesses | **NOT STARTED.** Gate C does not close without them, nor without Windows. | + +**Per-cell totals** were never printed: the CI step ran `zig build test` without `--summary all`. +Added. The 4-or-5 question — whether the fifth unaccounted block is platform-conditional like +`conv.zig`'s four — resolves free on the next run. + +### WHAT DOES NOT REOPEN + +Each cost a round-trip. A fresh session will reopen them unless something forbids it. + +- **The per-body divergence threshold form** — `‖Δx‖ + 2·r·‖vec(Δq)‖` against `1e-4·r`, reusing the + sleep criterion rather than inventing a second formula for one geometric fact. +- **The retained-pair set is SCAFFOLD, not engine.** Determined, measured, not a defect. +- **No CI aggregator job.** Refused with its motive in `engine-development-workflow.md` §7.3. +- **The witness keys are FROZEN** — `discrete-`, `reference-window-`, + `continuous-chain--`. +- **Provenance by commit trailer**, `Witness-regen:`, read from the PR HEAD commit and never from + the synthetic merge commit. + + +### P2-1 / P2-2 — the coverage test promised nine proofs and carried seven + +**P2-1 was a real coverage hole, not a phrase.** The test named for nine elements +asserted seven of them. Two were missing and each for its own reason: + +- **Element 3** lost its assertion at the previous round, when the + `max_islands > min_islands` predicate was un-attributed — the right correction, + which left the element observed by nothing in this test. +- **Element 7** never had one. `saw_sleep` loops over `mobile` and the five-box + stack satisfies it, so `lone_sleeper` was covered by an assertion that would + pass without it. **Codex MEASURED that**: forbidding `lone_sleeper` to sleep + left the test green. + +Guy's arbitrage of two options: ADD the two targeted observations rather than +rename the test. Both are now in the loop and in the assertion block: + +- `(7)` by IDENTITY — `s.world.bm.isSleeping(s.lone_sleeper)`, that body and no + other. +- `(3)` by MEMBERSHIP — `group_a[0]` and `group_b[0]` in the same island at some + frame. The full SEPARATE → TOGETHER → SEPARATE sequence stays in the dedicated + test, which remains the only place it is established; coverage asks whether the + element fires, not in what order. + +**Both counter-factuals were run and both attribute to the LINE, not merely to +the test:** + +| Counter-factual | Coverage test | Line that fell | Other tests | +|---|---|---|---| +| `setCanSleep(lone_sleeper, false)` | RED | `:789` — `lone_slept` | generic trace + witness | +| the two groups' `±4 m/s` approach → `0` | RED | `:788` — `groups_shared_island` | dedicated membership + witness | + +The first is Codex's own probe, and it is the one that mattered: before this +change it left the test green. + +**And the first counter-factual I reached for was NOT discriminating** — moving +the group centres from 35/38 to 30/42 reddens the dedicated test and the witness, +and leaves the coverage test GREEN, because the groups still close at ±4 m/s and +still meet inside 750 frames. Reported rather than quietly replaced: the same +instrument-not-the-object family, now on my own probe, and the fix was to remove +the CAUSE (the approach velocity) instead of increasing the distance. + +**P2-2 — REPORTED AS DONE AND ABSENT FROM THE CODE.** Corrected one round later; +see the entry below. The paragraph as first written also MISQUOTED the sentence it +claimed to have replaced — "the island count to FALL and RISE" where the code said +"the count to FALL and RISE" — and that misquote is the whole defect visible in one +line: the replacement pattern carried the same two extra words, so it matched +nothing, and the `grep` I verified with carried them too, so it could only confirm +itself. What the header actually said until the next round is the second form. + +### P1 — AN OBSERVATION HAS AN INSTANT, NOT ONLY AN OBJECT + +Codex again, and one axis over from the previous round: the identity repair left +`lone_slept` accumulating over 750 frames while element 7 exists to make the SLEEP +STATE trace non-constant inside the 60 frames the witness compares. Keeping that +body awake through the window and letting it sleep afterwards left the test green. +**The rule was already written, by me, four rounds earlier on the sensor lockstep — +a counter-factual has a correct INSTANT, not merely a correct object. A known rule, +recalled in writing, did not hold.** + +**The criterion is not "bound everything at 60" — it is WHICH ARTIFACT carries the +effect, and over how many frames that artifact is compared.** There are two spans: +the four discrete traces are compared over `window_frames` = 60 on all twelve +cells; the continuous chain over `chain_frames` = 1000. Bounding a chain-carried +observation at 60 would be FALSE, not strict. + +The eight other lines, MEASURED rather than classified by reading — first-fire +frame of each, one instrumented run: + +| line | element | artifact carrying it | frames compared | first fire | verdict | +|---|---|---|---|---|---| +| ground constraint | 1 | manifold cardinality | 60 | **0** | bounded at 60 | +| sleep, any body | 2 | chain (velocities zeroed) | 1000 | 29 | see below | +| groups share an island | 3 | island partition | 60 | **15** | bounded at 60 | +| two constraints, one pair key | 5 | manifold cardinality | 60 | **20** | bounded at 60 | +| sensor entered | 6 | chain (sets serialised) | 1000 | 7 | cardinality, unbounded | +| sensor exited | 6 | chain (sets serialised) | 1000 | 32 | cardinality, unbounded | +| the lone sleeper, by identity | 7 | sleep state | 60 | **29** | bounded at 60 | +| island count moves | coarse | island partition | 60 | **15** | bounded at 60 | +| stood on the riser | 8 | chain (character position) | 1000 | **422** | OUTSIDE 60, verified in the chain | +| stood on the walk slope | 8 | chain (character position) | 1000 | 29 | unbounded, chain-carried | +| slider still carries speed | 4 | chain (13 scalars) | 1000 | terminal | unbounded, chain-carried | + +Five discrete-carried observations now assert a FRAME through +`firstFireInside(first, win)`, which is strictly stronger than the boolean it +replaces: fired, AND fired inside what is compared. `null` is false — an +observation that never happened is not one that happened late. The riser at 422 is +why the blanket bound was refused: it is a real observation of a real mechanism, +verified over 1000 frames, and a 60-bound would have rejected it. + +**AND THE SWEEP FOUND A SECOND INSTANCE, which is the reason a sweep is not a +politeness.** The line labelled `(2)` fires at frame 29 — the SAME frame as element +7. Measured with element 7 forbidden to sleep: **the scene's first sleep is then +frame 100, outside the window.** So `(2)` was inside the compared window only +because element 7's body was, and the stack's own transition lies outside every +discrete byte compared. It is chain-carried — sleeping zeroes velocities — so it IS +verified, at frame 100 of 1000. Element 7's header had said this all along ("sleep +state and the island partition were both CONSTANT over the compared window before +it") while the assertion two hundred lines below claimed otherwise. The line is now +labelled for what it observes and deliberately NOT bounded at 60, because that +bound would fail. + +**Counter-factual, Codex's own** — `setCanSleep(lone_sleeper, false)` at creation, +re-enabled at frame 100: + +| | verdict | line | +|---|---|---| +| coverage test | **RED** | `:824` — the bounded identity assertion | +| generic discrete-variation test | RED | — | +| committed-witness comparison | RED | — | + +**The first attempt at that counter-factual was VACUOUS and it is reported, not +replaced in silence:** `rc=1` came from a compile error, my regex having matched a +`pub fn step` whose parameter is `frame` and not `f`. A compile error and an +assertion failure share an exit code — a tooling fact already written down in this +milestone — so `rc=1` proved nothing at all. The rerun greps the log for +`compilation errors` before reading any verdict. + +### P2 — A FIX REPORTED AS DONE AND ABSENT FROM THE REPOSITORY + +The previous round's commit message and brief entry both stated that element 3's +summary no longer required the island count to fall and rise. **The header still +carried it.** The cause is one pattern used twice: the replacement string said "the +island count to FALL and RISE" where the code says "the count to FALL and RISE", so +it matched nothing — and the verification was `grep -c "requires the island count +to FALL"`, the SAME wrong pattern, which returned 0 and which I read as the +sentence being gone rather than as my pattern never having matched in either +direction. **A verification that cannot distinguish "absent" from "my pattern is +wrong" is not a verification.** Same form as `push rc=0` with an unchanged SHA and +as the commit swallowed by a `| tail`: the intention is recorded, the object is not. + +Now applied, and verified BY READING THE OBJECT BACK rather than by re-running the +pattern. The standing practice: a replacement is confirmed by printing the resulting +text, and a `grep` returning zero is only evidence once the same `grep` has been +shown to return non-zero on something. + +**Announced-versus-present audit of both rounds**, since one instance is enough to +make these reports unreadable as measurements: + +| claim | round | in the code | +|---|---|---| +| sleeper observed by identity | `5b05003` | present | +| groups observed by membership | `5b05003` | present | +| counter-factuals attribute to `:789` / `:788` | `5b05003` | true, from the logs | +| element 3 summary aligned | `5b05003` | **ABSENT — corrected here** | +| brief entry inserted | `5b05003` | present | +| island probe un-attributed | `28a598b` | present | +| element indices swept | `28a598b` | present — refs 2, 3, 5, 7, 8, each correct in context | + +Two further stale references fell out of the same sweep, the superseded-text motif +in its smallest form: two comments still named `saw_sleep` and `lone_slept`, both +identifiers this round deleted, and the first also asserted that the sleep line is +"satisfied by the stack" — which the frame-100 measurement makes wrong in its +detail. Both rewritten; zero mentions of the five removed identifiers remain, +counted. + +### P1 — THIRD INSTANCE: AN AGGREGATE STANDING IN FOR A NAMED ELEMENT + +`first_any_sleep` read `slept_last_tick > 0`, which is a counter. The assertion +built on it claimed the STACK's transition at frame 100 while the value it read was +element 7's body at frame 29 — and the previous round's repair had MOVED the defect +one notch instead of closing it, having found the very same thing on the very same +line. **Codex measured the consequence: `can_sleep = false` on all five stack bodies +left the whole suite green.** + +**The question, asked of the nine in Guy's exact form — does each line that NAMES an +element read an identity of that element, or an aggregate another element can +satisfy?** Three answers were "an aggregate": + +| element | line reads | before | after | +|---|---|---|---| +| 1 half-space | `s.ground` on either half of `pair_key` | identity | unchanged | +| **2 stack** | `slept_last_tick > 0` | **AGGREGATE** | `isSleeping` over the five `s.stack` bodies | +| 3 groups | `s.group_a[0]`, `s.group_b[0]` in one island | identity | unchanged | +| 4 slider | `linearVelocity(s.slider)` | identity | unchanged | +| **5 mesh** | any pair carrying two constraints | **AGGREGATE** | the `s.mesh_body` / `s.mesh_sphere` pair, both halves | +| **6 sensor** | raw `entered` / `exited` set lengths | **AGGREGATE** | pairs equal to (`s.trigger`, `s.trigger_visitor`) entities | +| 7 sleeper | `isSleeping(s.lone_sleeper)` | identity | unchanged | +| 8 terrain | `ground.body == s.step_block` / `s.walk_slope` | identity | unchanged | +| 9 character | `chars.get(s.character).position` | identity | unchanged | +| coarse probe | island count — NAMES NO ELEMENT | n/a by design | unchanged | + +**And whether each fix changes an answer today was MEASURED, not asserted: 0 +non-mesh multi-constraint pairs and 0 non-target sensor events over the full run.** +So elements 5 and 6 were true for the right reason and their aggregates were correct +BY ACCIDENT; element 2's was not — satisfied at 29 by another element's body while +claiming 100. Two accidents and one lie, which is the honest arithmetic of a tacit +property: it holds until it does not, and nothing in the test says which case you +are in. + +Counter-factuals, each attributing to its own line, and each preceded by a +`compilation errors` grep because a compile error and an assertion failure share an +exit code: + +| counter-factual | verdict | line | +|---|---|---| +| the five stack bodies cannot sleep (Codex's) | **RED** | `:881` — the stack identity | +| mesh pair identity replaced by the slider's | RED | `:874` — the mesh pair | +| visitor identity replaced by the slider's | RED | `:896` — the sensor pair | + +`EntityId` carries no `eql`, so `samePair` compares both fields explicitly rather +than bitcasting to the packed `u64` — field order in a packed struct is a layout +accident, the rule the sensor dump already follows. + +### P2 — THE LOOP TRAVERSED 750 WHILE THE ASSERTIONS CLAIMED 1000 + +The five discrete-carried observations are bounded at `window_frames`; the rest were +documented one round earlier as verified over the chain's `chain_frames` = 1000. The +loop ran 750. A second sensor entry or exit anywhere in 750..999 escaped `exactly +one` completely — the claimed scope was the correct one and the loop was the part +that lied. + +Guy's arbitrage: traverse the span. **MEASURED at the full 1000, and no assertion +had to be relaxed to get there** — entered = 1, exited = 1, `max_x` = −60.1875, +`max_y` = 0.946324, slider v = 5.0. What the extra 250 frames buy is not a new +observation; it is the removal of an unexamined gap between what is asserted and +what is traversed. + +## Closing notes + +- **THE TEN FLOAT-ENVIRONMENT SITES, and the recipe rather than the list.** `ARCH-031` rule 5 is a + PREDICATE over sites — every thread start, every process entry — and this milestone measured what + happens when it is recorded as an enumeration instead: `install()`'s own doc comment named three + sites and declared a fourth inside a module to be a defect, while the repository now has three such + sites by design. So the list below is a SNAPSHOT with its derivation attached, and line numbers are + expected to drift; the derivation is the artifact. + + Derive it by walking `src/` (excluding `etch/zig_codegen/`, a declared exclusion) for `^pub fn main` + and for `Thread.spawn(`, and classify each spawn by whether the nearest preceding declaration is a + `test` block or a `fn` — **that classification is not optional and not cosmetic**: it is what + distinguishes `core/platform/threading.zig`, whose spawn sits inside a test and is therefore NOT a + site, from the three that are. The corpus's own enumeration of rule 5 lacked exactly this step. + + | site | kind | installed before P1-3 | + |---|---|---| + | `core/jobs/scheduler.zig:199` — job-system worker | thread | yes | + | `runtime/main.zig:182` — IPC reader | thread | **no** | + | `modules/render/shader_pipeline/hot_reload.zig:57` — shader watcher | thread | **no** | + | `runtime/main.zig:106` | entry | yes | + | `editor/main.zig:91` | entry | yes | + | `modules/forge/forge_3d/determinism_main.zig:49` | entry | **no** | + | `demo_etch_codegen.zig:17` | entry | **no** | + | `demo_etch_interp.zig:24` | entry | **no** | + | `foundation/simd/bench/paeth_bench.zig:12` | entry | **no** | + | `foundation/simd/bench/adler32_bench.zig:16` | entry | **no** | + + Three covered, seven not. **No exception was granted to a program that compares nothing today** — + the two SIMD benches run integer kernels and install anyway. The exemption is a judgement about + what a program will always be; a program that compared nothing becomes a fixture, and a float + kernel under an unowned environment measures a configuration that exists on no machine. The cost is + two instructions. + + The site that mattered most was the instrument's own: `determinism_main` ASSERTED the environment + through the module and never installed it, so every witness in the set was produced under whatever + the OS handed out. The assertion passed — which is exactly why an assertion is a DETECTION + mechanism and never a substitute for installation, and why the bit-neutrality control had to be + taken before P1-1 changed the scenario. + +- **THE NINE-ELEMENT SWEEP, AND IT IS THE SPECIFICATION THE BRIEF SHOULD HAVE CARRIED.** The frozen + Scope listed each element with THE REASON FOR ITS PRESENCE, and a reason for presence is satisfiable + BY PRESENCE. The two questions that had to be asked instead, per element: does its effect occur + BEFORE FRAME 60, and is that effect IN A COMPARED ARTIFACT. Three of nine failed them, and the sensor + failed both. + + | # | element | effect before f60 | artifact | before the sweep | + |---|---|---|---|---| + | 1 | half-space ground | contact | manifold | unchanged | + | 2 | five-box stack, cardinality 4 | yes | manifold + sleep | unchanged | + | **3** | **two colliding groups** | **real collision** | islands, FALL **and** RISE | **never met** — gap closed to 2.272 m and reopened | + | 4 | frictionless slider | 5 m/s retained | chain + window | unchanged | + | 5 | mesh multi-constraint | yes | manifold | unchanged | + | **6** | **sensor** | **2 deltas, `current` = 1** | **chain** | **0 deltas AND no artifact** | + | 7 | lone early sleeper | asleep | sleep | added by the previous round | + | **8** | riser and three ramps | character above the plane | chain | relief absent entirely | + | **9** | kinematic character | position + ground verdict | chain | **in no artifact** | + + `max_islands > min_islands` passed for 1000 frames without the announced collision ever happening — + the single island variation came from the sleeper disappearing. That criterion is KEPT and a targeted + one added beside it: a weak-but-true criterion is not wrong, it is insufficient, and substituting it + would lose what it does cover. + +- **TWO METHOD POINTS HARDER THAN THEIR FIX.** A COUNTER-FACTUAL EVALUATED AT THE WRONG INSTANT IS + GREEN FOR A TRUE REASON — the sensor lockstep compared at the LAST frame, where the visitor has + already left and both worlds legitimately agree; nothing distinguishes that from a working control. + It is the temporal variant of the family, and it cost one rewrite. And MY FIRST COLLISION + COUNTER-FACTUAL WAS NOT DISCRIMINATING: restoring ground friction still let 4 m/s arrive, so it was + discarded rather than kept for being green, and replaced by the measured cause — the original gap. + +- **BIT-NEUTRALITY OF THE FLOAT-ENVIRONMENT INSTALL — CLOSED, on three identifiers and not on a + characterisation.** The installation moved no bit, and the inherited state equalled the installed one, + measured in the only window where it was possible. + + | | | + |---|---| + | the P1-4 + P1-3 head | `2c29c7c5193209bdd8694995221c6306cab9eb57` | + | the matrix run on it | `32019448000` — `success`, 12 of 12 cells, `forge-determinism` present on 12 of 12 | + | the eight witness files in `9e23dd9..2c29c7c` | **none of them** — nine files in that diff, not one under `witnesses/` | + + The witnesses were UNCHANGED in the diff over that interval, so twelve green cells on `2c29c7c` are + the measurement. The window closed at `e851ff0`, which regenerates them: after that the witnesses + match by construction and the question is permanently unanswerable. `d627b89` was named for it once + in this brief and was the WRONG SHA — it carries P1-1. + +- **A PREDICTION OF MINE DID NOT COINCIDE WITH ITS OBSERVATION, AND I DID NOT SAY SO — self-audit, + Guy's finding.** Announcing the first cell counter-factual I wrote that «layer one, unconditional, + `CONSERVATION FAILED`, should fall on the twelve». What was observed was `CONTROL FAILED` in EIGHT + logs. Two different messages, two different counts, and the gap went uncommented. + + **Two errors in that one sentence, and the second is the instructive one.** «On the twelve» was + already false as I wrote it: the fault I then intended was on the `else` branch, which does not cover + Windows, so the correct expectation was eight and never twelve. Then the fault CHANGED LAYER — the + `pre-commit` hook refused it, so it moved onto the CI-passed number — and the observed message became + `CONTROL FAILED`. **I reported the change of layer and did NOT withdraw the prediction it replaced.** + That is precisely the motif this repository sweeps: a correction applied without retracting what it + supersedes, which is how the superseded text goes on being quoted. + + Layer one did not end up without a negative witness — but it was not red #1 that supplied it, it was + red #2 two rounds later, on the four Windows cells. The full, corrected accounting: + + | message | cells | run | what was faulted | + |---|---|---|---| + | `CONTROL FAILED` | **8**, non-Windows | 32044416532 | the CI-passed number → layer TWO | + | `CONSERVATION FAILED` | **4**, Windows | 32063230404 | the declared Windows constant → layer ONE | + +- **AND ONE INSTRUCTION WAS INTERNALLY INCONSISTENT, which is recorded because the right move was to + refuse rather than to choose.** The last order asked for `CONTROL FAILED` in a Windows cell log while + faulting the Windows branch ALONE. Those cannot both hold: faulting the declared constant fires layer + ONE, which returns before layer two is reached, so no fault on that branch can produce `CONTROL + FAILED`. Obtaining it would take faulting the PASSED number on Windows only — the other half, which + the same instruction excluded. Rather than silently pick one, the incompatibility was stated and the + choice handed back. Guy's own count: twentieth instance, and his. + +- **What worked.** The BILATERAL CONTROL, above everything else: two independent computations forced + onto one number — the dead-test closure against the suite's own collected total — caught a defect + that twenty-three passing fixtures did not, on its first application, and then caught a second when + the CI totals arrived and refuted a table written hours earlier. Too permissive overshoots, too + strict undershoots, and only equality excludes both; a fixture count has no such property. + Second, COUNTER-FACTUALS ON THE OBJECT rather than on the assertion — the pruning neutralised in + place, the chain witness corrupted at a named frame, the discrete witness corrupted in a named + section, the lint violation planted on the runner. Each one changed the thing being judged, and + three of them fired where reasoning had said they would not need to. + Third, PRE-INSCRIBING WHAT EACH OUTCOME WOULD MEAN before reading a number — done for the cache + cap, the warm timing and the ARM divergence frame. It is the only defence found in this milestone + against reading a measurement in the service of a decision already wanted. + +- **What deviated from the original spec.** Five recorded deviations, four of them additions to a + frozen Scope that named "exactly two" behavioural changes, each arbitrated: the explicit left fold + replacing float `@reduce` at 18 sites; the render-graph use-after-return; the dead-test guard; and + `shm.zig` at 8 sites, without which AArch64 does not compile at all. The Scope's "two" was not + exceeded so much as WRONG — its items are not a quota, they are pinnings of an arithmetic the + platform was choosing for us, and `@reduce` is the same act, left out on a recon measurement that + proved partial. + +- **What to flag explicitly in review:** + - **`ARCH-031` rule 2 is verified on ONE target of three, and `-Dcpu=baseline` is why.** Measured + at baseline: `@mulAdd` emits `fmadd` on aarch64 (base ISA — the backend could contract and does + not, so the zero-fused-instruction count over `forge_3d` is a real observation) and + `jmp fma@PLT` on x86_64, where FMA3 is Haswell-and-later so baseline has no fusion instruction + and the rule holds by absence of hardware rather than by respect of a contract. Structurally in + the engine's favour on x86 and worth nothing as evidence there; unwritten, a later reader would + take the contract for verified on three targets. + - **FOURTEEN INSTANCES OF ONE SHAPE — a verdict rendered on something other than its object.** + They are the milestone's real yield and they are not all mine. In order: the `@reduce` criterion + read off one backend's listing; the `'*m'` constraint checked by a build that analysed nothing; + a test target that collected zero and reported success; a trailer read from a synthetic merge + commit; a fixture agreeing with the implementation it tested; a binding vouching for itself; a + commented-out import read as code; a doc comment promising a comparison the code did not make; + `zig build lint` in no workflow while a doctrine was granted on a LOCAL counter-factual — Guy's + verdict, on Guy's condition; a declared table written on an inherited hypothesis the first CI run + refuted; a discriminant computed in ULP-at-1.0 and reported as ULP-at-5.0; a commit rejected by a + hook and swallowed by `| tail`, then CONFIRMED by an `Everything up-to-date`; and a CI step + deleted by an over-wide span, after which three runs went green with no determinism check; and a + verification probe whose pattern carried misplaced emphasis, reporting FAILURE on a correct + object. + **The last two are the same family and the most dangerous: silent DELETIONS that no green + reports.** The parade that survived is Guy's: verify the OBJECT after the act, never the + intention before it. + - **`CLAUDE.md` carried a DEAD INFERENCE, not a wrong measurement, and the distinction commanded + the fix.** M1.1.12 measured exactly 5.0 at f64 — dated, kept. What died is the reasoning built on + it. A journal keeps a fallen entry marked and cited; a living state document does not, because a + dead inference left there goes on being quoted. + +- **Final measurements.** + - **C1.1 level 1 GREEN**: `chain verdict: OK (1000 frames)` on all eight `(x86_64 cell × precision + × mode)` combinations, against witnesses generated on `ubuntu-24.04`. + - **C1.1 level 2 point 1 GREEN** on a real `ubuntu-24.04-arm` cell: the four discrete traces + identical to the x86_64 witness over 60 frames, both precisions, both modes. + - **Divergence frame `none within K=60`** on every cell. Bounded: it caps a CONTINUOUS deviation at + `1e-4 × body radius`, over 60 frames of ONE scene, and establishes NOTHING about level 3. On the + x86_64 cells it is a self-comparison and carries no ISA information at all. + - **Cross-mode agreement TRUE** at both precisions on `ubuntu-24.04`, the one cell where Zig's + self-hosted x86_64 backend meets LLVM — the comparison that produced blocker B2 before the fold. + - **12 CI cells green**, determinism step verified present on 12 of 12 by enumerating steps. **That + figure is from the pre-review close and is superseded by the post-correction run:** 11 of 12 green + on `d627b89`, the twelfth being the recorded `windows-2025 / ReleaseSafe` hang — `test runner + failed to respond`, ZERO failed assertions in the log, `run test 552 pass (552 total)` — signature + verified before the flake was invoked, job re-run. + - **`forge_3d` 527 → 552**; suite 1844 → 1866 on Linux and macOS, 1864 on Windows. Both figures moved + at the review: the pre-review close read 548 and 1859/1857, and the five tests added by P1-1, P1-2 + and P1-5 are the difference. + - **Slider residual**: 4 ULP at f32, 3 at f64, against `2^31` an energy injection needs. Rounding. + - **`windows-2025 / Debug` 14 min → 4 of a 20-min budget** once its cache was restored. + - **Zero external transcendental** across 41 416 call sites on three targets. + +- **Residual risks / tech debt left intentionally.** + - **M1.D.8** — the ARM `×23` compile-reuse collapse: located (129 of 132 steps), cause NOT + established, obvious candidate excluded by local measurement. + - **M1.D.9** — `bench.yml` keys its cache without the CPU axis and builds without `-Dcpu`. + - **M1.D.10** — `weldengine/setup-zig` purges at 2 GB a quantity that overlaps `.zig-cache`; it is + the mechanism behind the Windows corruption, and it lives in another repository. + - **`src/etch/zig_codegen/`** — 37 blocks, declared exclusion, owner M1.D.5. `cache.zig` has not + compiled under the pinned toolchain since `std.fs.cwd()` was removed. + - **The upstream Zig report** — written, not filed; filing is Guy's act. + - **M1.D.11** — a lint rule for `ARCH-031` rule 5's site set. Mechanical, and NOT built here by Guy's + ruling; until it exists a new thread or `main` can arrive uninstalled with nothing to say so. + - **The environment assertion's WRAPPER is unwitnessed** — the predicate is, the panic is not. Only + the always-true mutant survives; a sign-inverted one is caught everywhere. + - **`GroundState` is constant on the canonical scenario**, so the verdict field carries no + discrimination there and the POSITION does. Not coverage of `.on_steep_ground` / `.in_air`. + - **Six lines of French prose in `ci.yml`**, pre-existing from `aad6894d` — established by blame per + line, not inferred from age. + - **`ubuntu-24.04-arm` is a NEW target in CI** and this milestone is the first to compile for it. + What it surfaces next is not this milestone's, by the rule agreed when the cell landed. diff --git a/build.zig b/build.zig index 2c512974..ee512089 100644 --- a/build.zig +++ b/build.zig @@ -97,6 +97,22 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // M1.1.14 — Tier 0 gains a `foundation` dep. `foundation` imports nothing + // but std, so the graph stays acyclic (`ARCH-016`) and this is the shared + // bottom layer depending downward, not sideways. The one consumer is + // `platform/float_env.zig`, the platform layer's facade over + // `foundation/float_env.zig`: the float environment is INSTALLED by the + // platform layer and ASSERTED by `forge_3d`, and `forge_3d` may not import + // `weld_core` (a C1.1 exit metric), so the single owner of the register + // layout has to be reachable from `foundation`. + core_module.addImport("foundation", foundation_module); + // M1.1.14 — `ARCH-031` rule 5: the shader hot-reload watcher creates a thread, + // so it must INSTALL the float environment. It reaches the single definition in + // `foundation/math/float_env.zig` directly rather than through a re-export + // across a tier boundary, which session 1 of this milestone deleted for adding + // a name without adding a definition. + render_module.addImport("foundation", foundation_module); + const asset_pipeline_module = b.createModule(.{ .root_source_file = b.path("src/modules/asset_pipeline/root.zig"), .target = target, @@ -317,6 +333,146 @@ pub fn build(b: *std.Build) void { const forge_3d_test_step = b.step("test-forge-3d", "Run only the forge_3d solver tests"); forge_3d_test_step.dependOn(&forge_3d_tests_run.step); + // M1.1.14 — `zig build forge-determinism`: the determinism instrument, run + // at ONE worker over the canonical scenario. The step is deliberately an + // EXECUTABLE over a library (`tests/determinism/run.zig`) rather than a test: + // M1.1.25 replays it at N workers and M1.A on a rebuilt scheduler DAG, and a + // harness whose logic lived in its `main` would have to be re-entered through + // a process to be replayed. Its self-reproducibility and its artifact + // liveness are ALSO asserted inside `zig build test`, where the same library + // is exercised by `forge_3d`'s own suite. + // + // Its module carries the same imports as `forge_3d` itself because its root + // reaches the solver by relative path, exactly as the acceptance suite does. + const forge_determinism_module = b.createModule(.{ + .root_source_file = b.path("src/modules/forge/forge_3d/determinism_main.zig"), + .target = target, + .optimize = optimize, + }); + forge_determinism_module.addImport("foundation", foundation_module); + forge_determinism_module.addImport("weld_forge", forge_api_module); + forge_determinism_module.addOptions("build_options", forge_build_options); + const forge_determinism_exe = b.addExecutable(.{ + .name = "forge-determinism", + .root_module = forge_determinism_module, + }); + const forge_determinism_run = b.addRunArtifact(forge_determinism_exe); + if (b.args) |args| forge_determinism_run.addArgs(args); + const forge_determinism_step = b.step( + "forge-determinism", + "Run the canonical determinism scenario at one worker (M1.1.14)", + ); + forge_determinism_step.dependOn(&forge_determinism_run.step); + + // M1.1.14 — `zig build forge-asm-inventory`: the conformance test of + // `ARCH-031` rule 4, read in the EMITTED ASSEMBLY rather than in the source. + // `forge_3d` is compiled to assembly for the three targets the engine ships + // and every call site is inspected for a libm transcendental. + // + // The scanner is `tools/asm_inventory/` — a Zig program, not `grep`. Named + // substitution: the check has to anchor on the instruction mnemonic at line + // start, and the natural `\b` for that is a GNU extension that silently + // matches NOTHING on BSD grep. A scanner removes the class instead of + // dodging one instance of it, and it carries its own counter-factuals. + // + // A DEDICATED step and NOT part of `zig build test`: three cross-compiles + // of the whole physics module are minutes of work, `test` runs twice on + // every `git push` through the pre-push hook, and the answer is a property + // of the three TARGETS — it does not vary with the host that asks. So CI + // invokes it on one cell, exactly as it already does for + // `verify-synth-100`. The scanner's own tests are in `zig build test`. + const asm_inventory_module = b.createModule(.{ + .root_source_file = b.path("tools/asm_inventory/main.zig"), + .target = b.graph.host, + .optimize = .ReleaseSafe, + }); + const asm_inventory_exe = b.addExecutable(.{ + .name = "asm_inventory", + .root_module = asm_inventory_module, + }); + const asm_inventory_run = b.addRunArtifact(asm_inventory_exe); + + // The three targets of the determinism contract: the two OSes of level 1 + // and the ISA of level 2 (`engine-phase-1-criteria.md` C1.1). Each is + // pinned to `baseline` for the same reason every CI cell is — a runner + // image that changes processor generation must not change what is emitted + // (`ARCH-031` rule 6). + const inventory_targets = [_][]const u8{ + "x86_64-linux-gnu", + "x86_64-windows-gnu", + "aarch64-linux-gnu", + }; + for (inventory_targets) |triple| { + const query = std.Target.Query.parse(.{ + .arch_os_abi = triple, + .cpu_features = "baseline", + }) catch @panic("bad inventory target triple"); + const resolved = b.resolveTargetQuery(query); + + // The module chain has to be rebuilt per target: a `Module` is bound to + // its target at creation, so the host-bound handles above cannot serve. + // It mirrors the graph declared earlier in this file and nothing more — + // if that graph gains an edge, this loop is where it has to be repeated, + // and a missing edge is a compile error here rather than a silent gap. + const t_foundation = b.createModule(.{ + .root_source_file = b.path("src/foundation/root.zig"), + .target = resolved, + .optimize = .ReleaseSafe, + }); + const t_core = b.createModule(.{ + .root_source_file = b.path("src/core/root.zig"), + .target = resolved, + .optimize = .ReleaseSafe, + .link_libc = true, + }); + t_core.addImport("foundation", t_foundation); + const t_forge_api = b.createModule(.{ + .root_source_file = b.path("src/modules/forge/api/root.zig"), + .target = resolved, + .optimize = .ReleaseSafe, + }); + t_forge_api.addImport("foundation", t_foundation); + t_forge_api.addImport("weld_core", t_core); + const t_forge_3d = b.createModule(.{ + .root_source_file = b.path("src/modules/forge/forge_3d/root.zig"), + .target = resolved, + .optimize = .ReleaseSafe, + }); + t_forge_3d.addImport("foundation", t_foundation); + t_forge_3d.addImport("weld_forge", t_forge_api); + t_forge_3d.addOptions("build_options", forge_build_options); + + // Compiling `forge_3d/root.zig` DIRECTLY emits nothing — it is a + // re-export file and Zig is lazy. Measured before this indirection + // existed: `0 call sites examined` on all three targets, which the + // scanner refuses rather than reports as clean. The surface root forces + // the module's public functions into codegen; see its header. + const t_surface = b.createModule(.{ + .root_source_file = b.path("tools/asm_inventory/forge_3d_surface.zig"), + .target = resolved, + .optimize = .ReleaseSafe, + }); + t_surface.addImport("forge_3d", t_forge_3d); + + const obj = b.addObject(.{ + .name = b.fmt("forge_3d_asm_{s}", .{triple}), + .root_module = t_surface, + }); + asm_inventory_run.addFileArg(obj.getEmittedAsm()); + } + + const asm_inventory_step = b.step( + "forge-asm-inventory", + "Assert zero libm transcendental call in forge_3d assembly (ARCH-031 rule 4)", + ); + asm_inventory_step.dependOn(&asm_inventory_run.step); + + // The scanner's own counter-factuals ride in `zig build test`: a scanner + // that cannot fire reports a clean tree for the wrong reason, and that is + // the failure this suite exists to make impossible. + const asm_inventory_tests = b.addTest(.{ .root_module = asm_inventory_module }); + test_step.dependOn(&b.addRunArtifact(asm_inventory_tests).step); + // Out-of-tree tests. Each file is its own root_module and imports // `weld_core` to reach the engine internals. // Out-of-tree bindings tests need to reach files that live outside @@ -697,6 +853,9 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); runtime_module.addImport("weld_core", core_module); + // M1.1.14 — the main thread installs the engine float environment + // (`ARCH-031` rule 5) from its single definition under `foundation/math/`. + runtime_module.addImport("foundation", foundation_module); const runtime_exe = b.addExecutable(.{ .name = "weld-runtime", .root_module = runtime_module, @@ -718,6 +877,8 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); editor_module.addImport("weld_core", core_module); + // M1.1.14 — same as the runtime: the main thread is not born of a spawn. + editor_module.addImport("foundation", foundation_module); // S6 viewport blit pipeline embeds pre-compiled SPIR-V via the // shared `shaders` facade — the same module the S2 spike uses. editor_module.addImport("shaders", shaders_module); @@ -1261,6 +1422,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); demo_module.addImport("weld_core", core_module); + demo_module.addImport("foundation", foundation_module); demo_module.addImport("weld_etch", etch_module); demo_module.addImport("fixture_facade", fixture_facade_module); const demo_exe = b.addExecutable(.{ @@ -1530,6 +1692,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); demo_codegen_module.addImport("weld_core", core_module); + demo_codegen_module.addImport("foundation", foundation_module); demo_codegen_module.addImport("cooked_demo", cooked_demo_module); const demo_codegen_exe = b.addExecutable(.{ .name = "demo-etch-codegen", @@ -1788,6 +1951,55 @@ pub fn build(b: *std.Build) void { ); lint_step.dependOn(&lint_run.step); + // M1.1.14 — the dead-test guard, ACTIVE. Every file holding a `test` block + // must belong to some test target's analysis closure or to a declared + // exclusion. It builds nothing and runs nothing: it reads `build.zig` for its + // roots and walks relative imports, so it costs a tree scan and rides on the + // existing lint gate rather than earning a cell of its own. + // + // WHY IT IS ACTIVE AND NOT ADVISORY. An uncollected `test` block is never + // ANALYSED, so the code it instantiates loses type-checking, not just + // assertions — which is how `zig_codegen/cache.zig` stopped compiling at the + // Zig 0.16 pin unnoticed and how a use-after-return in the render graph + // survived ten milestones. Writing that as doctrine while nothing enforced it + // is the prohibition this milestone named against itself. + // + // It takes no paths, so `b.args` is deliberately NOT forwarded: the args of + // `zig build lint -- src` belong to the rule pass beside it. + const dead_tests_run = b.addRunArtifact(weld_lint_exe); + dead_tests_run.addArg("dead-tests"); + + // M1.1.14 review — `-Dexpect-collected=N` FORWARDED, and the reason it exists is + // that the conservation it feeds was unreachable. The tool's suite-derived check + // sat behind `--expect-collected=N` and NOTHING passed it: not this file, not the + // CI. So the lint printed an expected-collected line and then printed `clean`, + // having compared nothing — the fourth instance in this milestone of a control + // that exists and a path bypasses, this time inside the tool built against that + // family. + // + // Two layers, and only this one is optional. The tool now confronts the closure + // against a DECLARED per-OS total on every invocation, with no flag to forget; + // this option supplies the second number, the total the suite itself reported. + // CI passes it from the same job that ran the tests. Locally it is optional + // because `zig build lint` cannot run the suite to learn the figure, and an + // option that silently defaults to the closure's own arithmetic would be the + // defect again in a new costume. + if (b.option( + usize, + "expect-collected", + "Confront the dead-test closure with a collected-test total from `zig build test`", + )) |n| { + dead_tests_run.addArg(b.fmt("--expect-collected={d}", .{n})); + } + + lint_step.dependOn(&dead_tests_run.step); + + const dead_tests_step = b.step( + "dead-tests", + "Report every file with `test` blocks outside all test-target closures", + ); + dead_tests_step.dependOn(&dead_tests_run.step); + const lint_commit_run = b.addRunArtifact(weld_lint_exe); lint_commit_run.addArg("commit-msg"); if (b.args) |args| lint_commit_run.addArgs(args); @@ -1810,4 +2022,56 @@ pub fn build(b: *std.Build) void { const lint_runner_run = b.addRunArtifact(lint_runner_test); lint_runner_run.step.dependOn(&b.addInstallArtifact(weld_lint_exe, .{}).step); test_step.dependOn(&lint_runner_run.step); + + // M1.1.14 — the linter's OWN inline tests. The fixture corpus above proves + // each rule is WIRED into `runLint`, by running the real binary; it cannot + // prove a rule's logic, since the runner only reads an exit code and a + // fixture can say no more than "something fired". The two layers are + // complementary and neither substitutes for the other: a rule tested only + // inline can be left out of `main.zig` and still pass, and a rule covered + // only by fixtures can miscount, mis-scope its escape hatch, or flag its own + // prose without a single test noticing. + // + // Until this step existed, every `test` block under `tools/weld_lint/` was + // dead text — compiled by nothing, run by nothing. The root is `tests.zig` + // and NOT `main.zig`: a test build does not analyse a plain `const` import, + // so rooting here at `main.zig` ran zero tests while reporting success — + // measured, by appending a deliberately failing test and watching this step + // stay green. + const weld_lint_test_module = b.createModule(.{ + .root_source_file = b.path("tools/weld_lint/tests.zig"), + .target = b.graph.host, + .optimize = .Debug, + }); + const weld_lint_unit_test = b.addTest(.{ .root_module = weld_lint_test_module }); + test_step.dependOn(&b.addRunArtifact(weld_lint_unit_test).step); + + // M1.1.14 — three modules held `test` blocks that NO test target collected, + // so they had never run: `src/modules/render/` (49 blocks), `tools/bindgen/` + // (8) and `src/modules/audio/` (1). Found by counting source `test` blocks + // against the suite's own per-target totals, then confirming each by + // appending a deliberately failing test and watching the suite stay green. + // RENDER IS HELD, and the reason is what the sweep was for. Wiring its 49 + // never-run tests turns two of them red, and a probe settled why: the + // render-graph passes return a `Pass` whose `reads`/`writes` slices point at + // an anonymous literal in `buildPass`'s OWN STACK FRAME. Measured on + // `depth_prepass`: `writes.ptr` is a stack address, `depth_attachment` reads + // `false` immediately after the call, and a fresh call at the SAME address + // reads `true` — a use-after-return, live since M0.4 and invisible because + // nothing ever compiled the tests that assert it. `forward.zig` fails + // identically. The fix is an ownership decision in the render graph, not a + // determinism change, so it is reported rather than taken here. + const render_tests = b.addTest(.{ .root_module = render_module }); + test_step.dependOn(&b.addRunArtifact(render_tests).step); + + const audio_tests = b.addTest(.{ .root_module = audio_module }); + test_step.dependOn(&b.addRunArtifact(audio_tests).step); + + const bindgen_test_module = b.createModule(.{ + .root_source_file = b.path("tools/bindgen/tests.zig"), + .target = b.graph.host, + .optimize = .Debug, + }); + const bindgen_tests = b.addTest(.{ .root_module = bindgen_test_module }); + test_step.dependOn(&b.addRunArtifact(bindgen_tests).step); } diff --git a/src/core/ipc/shm.zig b/src/core/ipc/shm.zig index 62b7fc3d..c3e48925 100644 --- a/src/core/ipc/shm.zig +++ b/src/core/ipc/shm.zig @@ -65,7 +65,30 @@ pub const ShmRegion = struct { /// Page-aligned mapping pointer. Same address space-wise on the /// creator side; the attacher gets a fresh virtual address but /// the same physical pages. - ptr: [*]align(std.heap.pageSize()) u8, + /// + /// M1.1.14 — `page_size_min` AND NOT `pageSize()`, because a struct field's + /// alignment must be comptime-known and `pageSize()` is not on every target. + /// Where the page size is fixed it folds at comptime and this is a strict + /// no-op (x86_64 and Windows: min = max = 4096); where it is VARIABLE it + /// routes through `std.options.queryPageSize()` and a runtime atomic load, so + /// the field cannot be typed at all. aarch64-linux is that target — measured, + /// `page_size_min = 4096` against `page_size_max = 65536` — and the repository + /// simply did not compile for it until the ARM64 CI cell asked. + /// + /// THE BOUND IS THE MINIMUM, and the asymmetry is the whole point rather than + /// a preference. A pointer aligned to the real page is ALWAYS aligned to the + /// minimum, so this declaration is TRUE on every target; `page_size_max` would + /// declare `align(65536)` on aarch64-linux for memory `mmap` only guarantees + /// to 4096 — not a loose claim but a false one, and illegal behaviour caught + /// at runtime in Debug and ReleaseSafe. Weakening a claim is the safe + /// direction; strengthening one you cannot honour is not. + /// + /// NOT LOAD-BEARING, and that is a measurement rather than a reassurance: + /// every consumer of this pointer reaches it through an `@alignCast` to + /// `*Header` (`ipc/viewport.zig` lines 143, 197, 210), and `@alignOf(Header)` + /// is **8** on all four targets — three orders below the smallest + /// `page_size_min`. Nothing downstream asks for page alignment. + ptr: [*]align(std.heap.page_size_min) u8, /// Bytes mapped. POSIX `ftruncate`s to exactly this size; Windows /// rounds up to allocation granularity but `size` reports the /// caller-requested length. diff --git a/src/core/ipc/shm_posix.zig b/src/core/ipc/shm_posix.zig index 53ad0524..4230e7e4 100644 --- a/src/core/ipc/shm_posix.zig +++ b/src/core/ipc/shm_posix.zig @@ -100,7 +100,7 @@ pub const Backend = struct { /// (`fromFd`). Kept open for the lifetime of the `Backend` per the /// macOS quirk documented in the file header. Closed in `close()`. fd: i32, - ptr: [*]align(std.heap.pageSize()) u8, + ptr: [*]align(std.heap.page_size_min) u8, size: usize, pub fn create(name: []const u8, size: usize) Error!Backend { @@ -128,7 +128,7 @@ pub const Backend = struct { // `mmap` returns `MAP_FAILED == (void*)-1` on failure. if (raw == null or @intFromPtr(raw.?) == MAP_FAILED_RAW) return error.ShmMapFailed; - const ptr: [*]align(std.heap.pageSize()) u8 = @ptrCast(@alignCast(raw.?)); + const ptr: [*]align(std.heap.page_size_min) u8 = @ptrCast(@alignCast(raw.?)); return Backend{ .name_z = name_z, .gpa = gpa, @@ -155,7 +155,7 @@ pub const Backend = struct { const raw = sys.mmap(null, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (raw == null or @intFromPtr(raw.?) == MAP_FAILED_RAW) return error.ShmMapFailed; - const ptr: [*]align(std.heap.pageSize()) u8 = @ptrCast(@alignCast(raw.?)); + const ptr: [*]align(std.heap.page_size_min) u8 = @ptrCast(@alignCast(raw.?)); return Backend{ .name_z = name_z, .gpa = gpa, @@ -176,7 +176,7 @@ pub const Backend = struct { const raw = sys.mmap(null, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (raw == null or @intFromPtr(raw.?) == MAP_FAILED_RAW) return error.ShmMapFailed; - const ptr: [*]align(std.heap.pageSize()) u8 = @ptrCast(@alignCast(raw.?)); + const ptr: [*]align(std.heap.page_size_min) u8 = @ptrCast(@alignCast(raw.?)); return Backend{ .name_z = null, .gpa = std.heap.page_allocator, diff --git a/src/core/ipc/shm_windows.zig b/src/core/ipc/shm_windows.zig index 0b3c2b9e..13924f69 100644 --- a/src/core/ipc/shm_windows.zig +++ b/src/core/ipc/shm_windows.zig @@ -62,7 +62,7 @@ const Error = shm.Error; /// on Windows. pub const Backend = struct { mapping: Handle, - ptr: [*]align(std.heap.pageSize()) u8, + ptr: [*]align(std.heap.page_size_min) u8, size: usize, pub fn create(name: []const u8, size: usize) Error!Backend { @@ -89,7 +89,7 @@ pub const Backend = struct { const view = sys.MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, size); if (view == null) return error.ShmMapFailed; - const ptr: [*]align(std.heap.pageSize()) u8 = @ptrCast(@alignCast(view.?)); + const ptr: [*]align(std.heap.page_size_min) u8 = @ptrCast(@alignCast(view.?)); return Backend{ .mapping = mapping, .ptr = ptr, .size = size }; } @@ -107,7 +107,7 @@ pub const Backend = struct { const view = sys.MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, size); if (view == null) return error.ShmMapFailed; - const ptr: [*]align(std.heap.pageSize()) u8 = @ptrCast(@alignCast(view.?)); + const ptr: [*]align(std.heap.page_size_min) u8 = @ptrCast(@alignCast(view.?)); return Backend{ .mapping = mapping, .ptr = ptr, .size = size }; } diff --git a/src/core/jobs/scheduler.zig b/src/core/jobs/scheduler.zig index 617f6733..da78a0cf 100644 --- a/src/core/jobs/scheduler.zig +++ b/src/core/jobs/scheduler.zig @@ -38,6 +38,10 @@ const std = @import("std"); const archetype_mod = @import("../ecs/archetype.zig"); const worker_mod = @import("worker.zig"); +// M1.1.14 — the engine float environment, installed at the head of every worker +// thread (`ARCH-031` rule 5). Imported from its single definition rather than +// through a Tier 0 facade; the tier rule lives at that definition. +const float_env = @import("foundation").math.float_env; const Job = worker_mod.Job; const TrampolineFn = worker_mod.TrampolineFn; @@ -476,6 +480,18 @@ const livelock_budget_ns: i96 = 30 * std.time.ns_per_s; const livelock_check_stride: u64 = 1 << 16; fn workerMain(sched: *Scheduler, worker_idx: u32) void { + // M1.1.14 — FIRST statement of every engine worker thread. The float + // environment is per-thread and its default is not portable (it depends on + // the OS, the linked C runtime, and on what a graphics driver may have left + // behind), so it is installed rather than assumed (`ARCH-031` rule 5). + // + // This is the site the invariant names by role: the job system is + // work-stealing, so WHICH worker runs WHICH job is not a stable property, + // and a single worker with denormals flushed would make a result depend on + // scheduling. Installing here rather than in a module is what covers every + // thread instead of the ones a module happens to have created. + float_env.install(); + const self = &sched.workers[worker_idx]; // M0.2.1 / E5 — last_generation now u32 to match packed gen_and_n's // generation half. Initial 0 matches `gen_and_n: .init(0)` which diff --git a/src/core/root.zig b/src/core/root.zig index d12bb2aa..8c0170ea 100644 --- a/src/core/root.zig +++ b/src/core/root.zig @@ -5,6 +5,8 @@ //! `platform`, and `testing`. Each namespace is documented at its //! declaration below. +const builtin = @import("builtin"); + /// ECS namespace — single canonical entry point at /// `src/core/ecs/root.zig` (M0.1 / E7). The root provides both: /// * Flat public types : `ecs.World`, `ecs.EntityId`, `ecs.Query`, @@ -141,6 +143,18 @@ comptime { _ = ipc.framing; _ = ipc.transport; _ = ipc.shm; + // M1.1.14 — the POSIX backend is selected inside `shm.zig` at comptime, so + // referencing `ipc.shm` never analyses it. Its one test had never run. + // + // The guard MIRRORS `shm.zig`'s own dispatch and is not decoration: the file + // opens with a `@compileError` for any other OS, so an unconditional wire-in + // breaks every Windows build. It did, and `zig build test` on a POSIX host + // could not show it — `forge-asm-inventory` cross-compiles to + // `x86_64-windows-gnu` from ANY host, and that step is not part of + // `zig build test`. A green local suite is a smaller claim than a green cell. + if (builtin.os.tag == .linux or builtin.os.tag == .macos) { + _ = @import("ipc/shm_posix.zig"); + } _ = ipc.viewport; _ = ipc.connection; _ = ipc.server; diff --git a/src/demo_etch_codegen.zig b/src/demo_etch_codegen.zig index 3826aa7a..eae47d20 100644 --- a/src/demo_etch_codegen.zig +++ b/src/demo_etch_codegen.zig @@ -4,6 +4,7 @@ //! diff it against `bench/fixtures/demo_5_rules_codegen.expected.txt`. const std = @import("std"); +const foundation = @import("foundation"); const weld_core = @import("weld_core"); const cooked = @import("cooked_demo"); @@ -14,6 +15,13 @@ const EntityId = weld_core.ecs.entity.EntityId; const Ticks: u32 = 10; pub fn main(init: std.process.Init) !void { + // `ARCH-031` rule 5 — every PROCESS ENTRY POINT installs the float environment, + // with no exception for a program that compares nothing today. An exception of + // that shape is a judgement, and a judgement does not survive: a program that + // compared nothing becomes a fixture, and a float-kernel bench measured under + // an unpinned environment measures a configuration that exists nowhere. + foundation.math.float_env.install(); + const gpa = init.gpa; const io = init.io; diff --git a/src/demo_etch_interp.zig b/src/demo_etch_interp.zig index 12306027..721f7537 100644 --- a/src/demo_etch_interp.zig +++ b/src/demo_etch_interp.zig @@ -7,6 +7,7 @@ //! Demo S4 OK | mode=ReleaseSafe | entities=1000 | rules=5 | ticks=60 | rules_matched=N | errors=0 | total=Tms const std = @import("std"); +const foundation = @import("foundation"); const builtin = @import("builtin"); const etch = @import("weld_etch"); const weld_core = @import("weld_core"); @@ -21,6 +22,13 @@ const Entities: u32 = 1_000; const Ticks: u32 = 60; pub fn main(init: std.process.Init) !void { + // `ARCH-031` rule 5 — every PROCESS ENTRY POINT installs the float environment, + // with no exception for a program that compares nothing today. An exception of + // that shape is a judgement, and a judgement does not survive: a program that + // compared nothing becomes a fixture, and a float-kernel bench measured under + // an unpinned environment measures a configuration that exists nowhere. + foundation.math.float_env.install(); + const gpa = init.gpa; const io = init.io; diff --git a/src/editor/main.zig b/src/editor/main.zig index e60ca3be..c18fdc43 100644 --- a/src/editor/main.zig +++ b/src/editor/main.zig @@ -28,6 +28,9 @@ const std = @import("std"); const builtin = @import("builtin"); const weld_core = @import("weld_core"); +// M1.1.14 — the engine float environment (`ARCH-031` rule 5): the main thread +// is not born of a spawn, so it is installed here rather than by the job system. +const foundation = @import("foundation"); const ipc = weld_core.ipc; const framing = ipc.framing; const messages = ipc.messages; @@ -86,6 +89,12 @@ fn sleepMs(ms: u64) void { } pub fn main(init: std.process.Init) !void { + // M1.1.14 — the main thread is not born of a spawn, so it does not pass + // through the job system's worker entry and receives the engine float + // environment here instead (`ARCH-031` rule 5, `engine-platform.md` §4). + // First statement, before anything can compute. + foundation.math.float_env.install(); + // Full Juicy Main (engine-zig-conventions §2 — `Init` for dev tools): // `init.arena` is process-lifetime + auto-cleaned; `init.io` drives // the executable-directory lookup used to resolve the runtime path. diff --git a/src/etch/root.zig b/src/etch/root.zig index 39c19e2c..8d07b652 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -55,13 +55,36 @@ comptime { _ = @import("test_runner.zig"); // M1.0.17 — explicit wire-in of `types.zig`'s inline tests (E0101, // scene/prefab/const validation, the M1.0.17 resource-collection acceptance - // tests, …), consistent with the sibling entries above. NOTE: unlike those, - // this one is *belt-and-suspenders* — a deliberate-failure probe confirmed - // `zig build test` already collects `types.zig`'s `test` blocks via the - // `pub const types` re-export (a public re-export of the root module is - // force-analyzed, tests included), WITH and WITHOUT this line (etch_tests - // count identical). Kept for explicitness; not load-bearing (§13 residual). + // tests, …), consistent with the sibling entries above. + // + // M1.1.14 — THIS LINE IS LOAD-BEARING, and the note that said otherwise was + // wrong on its MECHANISM while right on its observation. It claimed "a public + // re-export of the root module is force-analyzed, tests included", which + // directly contradicts the paragraph above this block, in this same file. A + // four-case experiment settles it: with `pub const leaf = @import("leaf.zig")` + // alone the root collects ZERO of leaf's tests, with or without a test of its + // own; only a `comptime { _ = leaf; }` reference collects them. The original + // probe's observation was sound — removing this line left the count unchanged + // — because `types.zig` is ALSO reached through `interp.zig`, which is pinned + // above and uses its declarations. Attributing that to the re-export turned a + // true measurement into a false general rule, and the rule is what a later + // reader would have acted on. _ = @import("types.zig"); + // M1.1.14 — `zig_codegen/root.zig` carries the correct reference guard for its + // own three test files, and nothing ever ran it: the only path to it was + // `pub const codegen_zig`, the form that does not analyse. Thirty-seven test + // blocks — including `lower_test.zig`'s twenty-six — had never executed. + // + // THE WIRE-IN IS HELD, NOT FORGOTTEN, and the reason is a bigger finding than + // the dead tests: `zig_codegen/cache.zig` does not COMPILE under the pinned + // Zig 0.16. `std.fs.cwd()` was removed and `Io.Dir` carries no `realpath`, so + // `writeHash`, `readCachedHash` and `root.writeFileAndCache` have been dead + // code since the 0.16 pin — and `root.zig` already documents `cookTree` as + // having "no current in-tree consumer". Repairing it is not a rename: the + // 0.16 filesystem API takes an `io` parameter these functions do not have, so + // it changes the codegen cache's public signatures. That is an Etch decision + // and not a determinism one. Enable this line with that repair. + // _ = @import("zig_codegen/root.zig"); } /// M1.0.4 scene cook — `.scene.etch` source → the neutral Tier-0 scene model diff --git a/src/etch/zig_codegen/tests/cache_test.zig b/src/etch/zig_codegen/tests/cache_test.zig index a304de14..1710ef10 100644 --- a/src/etch/zig_codegen/tests/cache_test.zig +++ b/src/etch/zig_codegen/tests/cache_test.zig @@ -7,7 +7,10 @@ test "identical content hits cache, no regeneration" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const cache_dir = try tmp.dir.realpathAlloc(gpa, "."); + // `Io.Dir` carries no `realpath` in Zig 0.16. `tmpDir` creates + // `.zig-cache/tmp/` relative to CWD and `sub_path` is public; + // the cache API is CWD-relative and creates the directory itself. + const cache_dir = try std.fs.path.join(gpa, &.{ ".zig-cache", "tmp", &tmp.sub_path }); defer gpa.free(cache_dir); const src = "component A { x: int = 0 }"; @@ -21,7 +24,10 @@ test "modified content invalidates cache, regenerates" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const cache_dir = try tmp.dir.realpathAlloc(gpa, "."); + // `Io.Dir` carries no `realpath` in Zig 0.16. `tmpDir` creates + // `.zig-cache/tmp/` relative to CWD and `sub_path` is public; + // the cache API is CWD-relative and creates the directory itself. + const cache_dir = try std.fs.path.join(gpa, &.{ ".zig-cache", "tmp", &tmp.sub_path }); defer gpa.free(cache_dir); const src1 = "component A { x: int = 0 }"; diff --git a/src/etch/zig_codegen/tests/errors_test.zig b/src/etch/zig_codegen/tests/errors_test.zig index 8576093a..a667c608 100644 --- a/src/etch/zig_codegen/tests/errors_test.zig +++ b/src/etch/zig_codegen/tests/errors_test.zig @@ -3,6 +3,9 @@ const std = @import("std"); const ast_mod = @import("../../ast.zig"); const root = @import("../root.zig"); +// `SourceSpan` is private in `ast.zig` and `pub` in `token.zig`, which declares +// it. The test reaches its owner rather than widening a production surface. +const token_mod = @import("../../token.zig"); test "UnsupportedConstruct surfaced for out-of-subset input" { // Build an AST with a let whose value is a `path` ExprKind (out of the @@ -14,7 +17,7 @@ test "UnsupportedConstruct surfaced for out-of-subset input" { var arena = try ast_mod.AstArena.init(gpa); defer arena.deinit(gpa); - const span: ast_mod.SourceSpan = .{ .byte_start = 0, .byte_end = 0 }; + const span: token_mod.SourceSpan = .{ .byte_start = 0, .byte_end = 0 }; const rule_name = try arena.strings.intern(gpa, "bad"); const path_expr = try arena.addExpr(gpa, .path, 0, span); const let_id = try arena.addLetStmt(gpa, .{ @@ -42,7 +45,7 @@ test "UnsupportedConstruct surfaced for out-of-subset input" { var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); - try std.testing.expectError(root.CodegenError.UnsupportedConstruct, root.generateToBuffer(gpa, &arena, "", &out)); + try std.testing.expectError(root.errors.CodegenError.UnsupportedConstruct, root.generateToBuffer(gpa, &arena, "", &out)); } test "NonPodComponent surfaced before codegen entry" { @@ -54,7 +57,7 @@ test "NonPodComponent surfaced before codegen entry" { var arena = try ast_mod.AstArena.init(gpa); defer arena.deinit(gpa); - const span: ast_mod.SourceSpan = .{ .byte_start = 0, .byte_end = 0 }; + const span: token_mod.SourceSpan = .{ .byte_start = 0, .byte_end = 0 }; const comp_name = try arena.strings.intern(gpa, "Bad"); const field_name = try arena.strings.intern(gpa, "x"); const type_name = try arena.strings.intern(gpa, "NotABuiltin"); @@ -81,5 +84,5 @@ test "NonPodComponent surfaced before codegen entry" { var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); - try std.testing.expectError(root.CodegenError.NonPodComponent, root.generateToBuffer(gpa, &arena, "", &out)); + try std.testing.expectError(root.errors.CodegenError.NonPodComponent, root.generateToBuffer(gpa, &arena, "", &out)); } diff --git a/src/etch/zig_codegen/tests/lower_test.zig b/src/etch/zig_codegen/tests/lower_test.zig index 43b31f47..3a801435 100644 --- a/src/etch/zig_codegen/tests/lower_test.zig +++ b/src/etch/zig_codegen/tests/lower_test.zig @@ -386,7 +386,7 @@ test "lowers a throws fn to the hidden __err out-param and rejects unsanctioned var nested: std.ArrayListUnmanaged(u8) = .empty; defer nested.deinit(gpa); try std.testing.expectError( - root.CodegenError.UnsupportedConstruct, + root.errors.CodegenError.UnsupportedConstruct, root.generateToBuffer(gpa, &pr.ast, "", &nested), ); } @@ -585,7 +585,7 @@ test "collection allocations require the frame arena: fn-body push fails loud (M var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try std.testing.expectError( - root.CodegenError.UnsupportedConstruct, + root.errors.CodegenError.UnsupportedConstruct, root.generateToBuffer(gpa, &pr.ast, "", &out), ); } @@ -725,7 +725,7 @@ test "closure captures are bounded to POD scalars: string capture fails loud (M0 var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try std.testing.expectError( - root.CodegenError.UnsupportedConstruct, + root.errors.CodegenError.UnsupportedConstruct, root.generateToBuffer(gpa, &pr.ast, "", &out), ); } @@ -847,7 +847,7 @@ test "throwing closure call outside a let initializer fails loud (M0.8 E3-C tran var out: std.ArrayListUnmanaged(u8) = .empty; defer out.deinit(gpa); try std.testing.expectError( - root.CodegenError.UnsupportedConstruct, + root.errors.CodegenError.UnsupportedConstruct, root.generateToBuffer(gpa, &pr.ast, "", &out), ); } diff --git a/src/foundation/math/aabb.zig b/src/foundation/math/aabb.zig index bf846258..379d28fd 100644 --- a/src/foundation/math/aabb.zig +++ b/src/foundation/math/aabb.zig @@ -7,6 +7,7 @@ const std = @import("std"); const vec = @import("vec.zig"); +const reduce = @import("reduce.zig"); /// Axis-aligned bounding box over 3-vectors of scalar `T`, stored as its /// minimum and maximum corners. @@ -106,7 +107,7 @@ pub fn Aabb(comptime T: type) type { pub fn overlapsHalfSpace(self: Self, normal: Vec3T, distance: T) bool { const zeros: @Vector(3, T) = @splat(0); const lowest = @select(T, normal.data >= zeros, self.min.data, self.max.data); - return @reduce(.Add, normal.data * lowest) <= distance; + return reduce.foldAdd(normal.data * lowest) <= distance; } /// Geometric center. @@ -221,8 +222,8 @@ pub fn Aabb(comptime T: type) type { const near = @select(T, dir_is_zero, unbounded_lo, @min(a, b)); const far = @select(T, dir_is_zero, unbounded_hi, @max(a, b)); - const enter = @reduce(.Max, near); - const exit = @reduce(.Min, far); + const enter = reduce.foldMax(near); + const exit = reduce.foldMin(far); // Strict `>`: `enter == exit` is a single-parameter graze, a hit // under the same inclusive convention as `overlaps`/`contains`. if (enter > exit) return null; diff --git a/src/foundation/math/float_env.zig b/src/foundation/math/float_env.zig new file mode 100644 index 00000000..8373cece --- /dev/null +++ b/src/foundation/math/float_env.zig @@ -0,0 +1,584 @@ +//! `foundation/math/float_env.zig` — the engine's floating-point EXECUTION state. +//! +//! `ARCH-031` rule 5: round-to-nearest-even, denormals PRESERVED — so FTZ and +//! DAZ off — on **every engine thread**. Not a performance preference. A thread +//! that flushes denormals computes a different number from one that keeps them, +//! and the job system is work-stealing, so *which* thread runs *which* job is +//! not a stable property: an unpinned float environment makes a result depend +//! on scheduling. The default state is NOT portable — it depends on the OS, on +//! the linked C runtime, and on what a graphics driver may have changed inside +//! the process — so it is **installed**, never assumed. +//! +//! **Two verbs, two owners, one definition — and that is what fixes the file's +//! address.** The contract of `ARCH-031` rule 5 has halves that must not be +//! confused: +//! +//! - **Installing** belongs to Tier 0 (`engine-platform.md` §4 — *Threading*): +//! at thread creation for the threads the job system spawns, and at process +//! entry for the main thread, which is not born of a spawn. A module that +//! installed it would leave divergent every thread it did not create. +//! - **Asserting** belongs to every module whose output is compared, at its own +//! entry point. A local re-install would MASK the defect instead of reporting +//! it, and leave the divergence standing for every other consumer. +//! +//! `forge_3d` is such a module, and `engine-phase-1-criteria.md` C1.1, block +//! *Métriques*, binds its dependencies to a WHITELIST OF EXACTLY TWO: +//! `src/foundation/math/`, and the public ECS components of +//! `src/modules/forge/api/`. Not "anything but `weld_core`" — two entries, and +//! the paraphrase is what put this file at the wrong address once already. So +//! the definition lives under `foundation/math/`, which is one of the two, and +//! `forge_3d` gains no third dependency by asserting. +//! +//! There is deliberately NO facade in `core/platform/`. Tier 0 imports this file +//! directly — the downward direction, acyclic, `foundation` importing nothing +//! but `std`. A file whose only content is a re-export across a tier boundary +//! adds a name without adding a definition, and splitting the reader from the +//! writer would put ONE register layout in two places: the drift shape this +//! repository has already paid for once (`contactMargin`, duplicated in +//! `fast_paths.zig` while both copies were private). One definition, however +//! many callers — the number is not a property this file gets to fix, and the +//! sentence that used to end «three callers» went false the moment the set grew +//! (see `install`). +//! +//! `ARCH-031`'s own *Sources de vérité* line splits the same way, which is the +//! corroboration rather than the argument: `engine-coordinate-system.md` §2 — +//! the owner of `foundation/math/` — for execution semantics, and +//! `engine-platform.md` for the FPU state and the CI pinning. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// IEEE-754 rounding direction. +/// +/// The four values are named after the standard's attributes rather than after +/// either ISA's encoding, BECAUSE the two encodings disagree: x86's `RC` field +/// spells down/up as `01`/`10` and AArch64's `RMode` spells them `10`/`01`. A +/// shared enum with a per-ISA decode is what keeps a reader from concluding +/// that a raw control word can be compared across architectures. +pub const Rounding = enum { + /// Round to nearest, ties to even. The engine's mode, and the only one + /// under which `foundation/math/trig.zig`'s magic-constant integer + /// extraction computes what it says it computes. + nearest_even, + /// Round toward −∞. + toward_negative, + /// Round toward +∞. + toward_positive, + /// Round toward zero (truncate). + toward_zero, +}; + +/// The three properties of the float environment that change a result. +/// +/// Exception MASKS are deliberately absent. They govern whether an invalid +/// operation traps, not what value it produces, so two threads that disagree +/// about them still compute the same bits — and trapping is a debugging choice +/// that no determinism contract should freeze. +pub const State = struct { + /// Active rounding direction. + rounding: Rounding, + /// Whether a denormal RESULT is flushed to zero. + flush_to_zero: bool, + /// Whether a denormal INPUT is treated as zero. + /// + /// On AArch64 this is not a separate control: `FPCR.FZ` flushes inputs and + /// outputs together, so both fields report that one bit. The struct keeps + /// two fields anyway because x86_64 really does have two, and collapsing + /// them would make an x86 state with `DAZ` set and `FTZ` clear + /// unrepresentable — a state a third-party driver can and does leave behind. + denormals_are_zero: bool, + + /// Bit-for-bit equality. Written out rather than `std.meta.eql` so that a + /// field added later has to be considered here rather than silently joining + /// the comparison. + pub fn eql(self: State, other: State) bool { + return self.rounding == other.rounding and + self.flush_to_zero == other.flush_to_zero and + self.denormals_are_zero == other.denormals_are_zero; + } +}; + +/// The state `ARCH-031` rule 5 requires on every engine thread. +pub const engine_default: State = .{ + .rounding = .nearest_even, + .flush_to_zero = false, + .denormals_are_zero = false, +}; + +/// Whether this target exposes a float control register this file can read and +/// write. +/// +/// True on x86_64 (`MXCSR`) and AArch64 (`FPCR`) — which is every cell of the +/// CI matrix and both development machines. On any other target `read` returns +/// `null` and `install` is a no-op: reporting "unknown" is the honest answer, +/// and it is strictly better than an `install` that silently does nothing while +/// claiming success. +/// +/// **The x86_64 arm of this file is validated on CI cells and NOWHERE ELSE, and +/// that is measured, not assumed.** The primary development machine is Apple +/// Silicon, and an `x86_64-macos` build of these tests runs under Rosetta 2, +/// which does not emulate `MXCSR`: `stmxcsr` there returns a constant `0x0000` +/// — not a value real hardware can hold, since the ABI leaves the six exception +/// masks set at `0x1F80` — and every write to the rounding-control field is +/// dropped. `install` still takes effect at the arithmetic level (an `FTZ` +/// write does flush a denormal), so the writer is partially witnessed; the +/// READER is not witnessed at all. Running these tests under an x86 emulator is +/// therefore not a witness in either direction, and the discrimination test +/// below is what makes that visible instead of silent: it fails there rather +/// than passing vacuously. +pub const controllable: bool = switch (builtin.cpu.arch) { + .x86_64, .aarch64, .aarch64_be => true, + else => false, +}; + +// --- x86_64: MXCSR ---------------------------------------------------------- +// +// Bit 6 = DAZ (denormals are zero, input side). +// Bits 13:14 = RC, the rounding control: 00 nearest-even, 01 −∞, 10 +∞, 11 zero. +// Bit 15 = FTZ (flush to zero, result side). +// Bits 7..12 are the exception masks and bits 0..5 the sticky exception flags; +// both are preserved verbatim by `install`, which only ever clears the three +// fields it owns. + +const mxcsr_daz_bit: u32 = 1 << 6; +const mxcsr_ftz_bit: u32 = 1 << 15; +const mxcsr_rc_shift: u5 = 13; +const mxcsr_rc_mask: u32 = 0b11 << mxcsr_rc_shift; + +// **The two constraint spellings below are the ONLY pair that is both accepted +// and CORRECT on the two backends Zig 0.16 uses for x86_64**, and establishing +// that cost a red CI cell. `zig build` in Debug goes through the SELF-HOSTED +// x86 backend; ReleaseSafe and ReleaseFast go through LLVM. They do not accept +// the same inline-assembly surface, and — the dangerous part — they do not give +// the same MEANING to everything they both accept. Measured, by disassembling +// the emitted object rather than by reading exit codes: +// +// - `"*m"` (the original spelling here): LLVM accepts it and emits correct +// code; the self-hosted backend rejects it outright. A green ReleaseSafe +// leg and a red Debug leg, which is exactly what CI reported. +// - `(%[p])` with an `"r"` pointer, and `0(%[p])`: same split — correct under +// LLVM, `invalid memory operand` under the self-hosted backend. So is any +// other explicit memory reference in the template, including `(%rsp)`. +// - `"m"` as an INPUT: accepted by BOTH and correct on only one. The +// self-hosted backend loads the VALUE (`ldmxcsr 0x8(%rsp)` after storing it +// there); LLVM materialises a POINTER into a slot and hands the instruction +// that slot, so `ldmxcsr` would configure the FPU from the low half of a +// stack ADDRESS. A form that compiles everywhere and is silently garbage in +// the shipped optimisation mode is worse than a compile error, and it is +// the reason these notes are here rather than in a commit message. +// - `"+m"`: crashes the LLVM backend (`Elementtype attribute can only be +// applied for indirect constraints`). +// +// What survives is `"=m"` as an OUTPUT, on both instructions. For `stmxcsr` +// that is its true direction. For `ldmxcsr` it is a deliberate lie — the +// instruction READS that memory — and the lie has exactly one consequence: the +// optimiser is entitled to treat the slot as undefined on entry and drop the +// store that put the value there. That is why the value is written through a +// `*volatile u32` first: a volatile store is a language-level guarantee, not a +// hope about an optimiser, and the disassembly confirms it survives at +// ReleaseFast. Nothing reads `local` afterwards, so the lie costs nothing else. + +fn readMxcsr() u32 { + var word: u32 = 0; + // Only the assembly writes `word`, which the compiler cannot see, so the + // `var` needs this to avoid `local variable is never mutated`. + _ = &word; + asm volatile ("stmxcsr %[out]" + : [out] "=m" (word), + : + : .{ .memory = true }); + return word; +} + +fn writeMxcsr(word: u32) void { + var local: u32 = 0; + const slot: *volatile u32 = &local; + slot.* = word; + asm volatile ("ldmxcsr %[in]" + : [in] "=m" (local), + : + : .{ .memory = true }); +} + +// --- AArch64: FPCR ---------------------------------------------------------- +// +// Bit 24 = FZ (flush to zero — inputs AND outputs). +// Bits 22:23 = RMode: 00 nearest-even, 01 +∞, 10 −∞, 11 zero. NOTE the swap +// against x86's RC, which is exactly why `Rounding` is an enum and not a raw +// two-bit field. +// Bit 19 = FZ16, the half-precision flush control. Left untouched: no engine +// path computes in `f16`, so writing it would be changing a bit nothing reads. + +const fpcr_fz_bit: u64 = 1 << 24; +const fpcr_rmode_shift: u6 = 22; +const fpcr_rmode_mask: u64 = 0b11 << fpcr_rmode_shift; + +fn readFpcr() u64 { + return asm volatile ("mrs %[out], fpcr" + : [out] "=r" (-> u64), + ); +} + +fn writeFpcr(word: u64) void { + asm volatile ("msr fpcr, %[in]" + : + : [in] "r" (word), + : .{ .memory = true }); +} + +// --- Public surface --------------------------------------------------------- + +/// The float environment of the CALLING thread, or `null` when this target has +/// no control register this file knows (`controllable`). +/// +/// Reads only. It is the primitive every deterministic module asserts on, and a +/// reader that repaired what it found would defeat its own purpose. +pub fn read() ?State { + if (!controllable) return null; + + switch (builtin.cpu.arch) { + .x86_64 => { + const word = readMxcsr(); + return .{ + .rounding = switch (@as(u2, @truncate(word >> mxcsr_rc_shift))) { + 0b00 => .nearest_even, + 0b01 => .toward_negative, + 0b10 => .toward_positive, + 0b11 => .toward_zero, + }, + .flush_to_zero = (word & mxcsr_ftz_bit) != 0, + .denormals_are_zero = (word & mxcsr_daz_bit) != 0, + }; + }, + .aarch64, .aarch64_be => { + const word = readFpcr(); + const flush = (word & fpcr_fz_bit) != 0; + return .{ + .rounding = switch (@as(u2, @truncate(word >> fpcr_rmode_shift))) { + 0b00 => .nearest_even, + 0b01 => .toward_positive, + 0b10 => .toward_negative, + 0b11 => .toward_zero, + }, + // One bit, reported under both names — see `State`. + .flush_to_zero = flush, + .denormals_are_zero = flush, + }; + }, + else => unreachable, + } +} + +/// An opaque snapshot of the raw control word of the calling thread. +/// +/// Opaque on purpose: the same `Raw` means different things on the two +/// architectures — see the `Rounding` doc comment on the encoding swap — so it +/// is only ever handed back to `restore` on the thread it came from, never +/// compared, stored or transported. +pub const Raw = u64; + +/// Snapshot the raw control word of the calling thread, for `restore`. +/// +/// Snapshot/restore is the complete minimal surface of a per-thread control +/// register alongside `read` and `installState`, and it is what lets a consumer +/// confront its own entry-point check with a real perturbation without holding +/// a second copy of the register layout — the drift shape this file exists to +/// prevent. `0` on a target with no control register; `restore` ignores it. +pub fn save() Raw { + if (!controllable) return 0; + return switch (builtin.cpu.arch) { + .x86_64 => readMxcsr(), + .aarch64, .aarch64_be => readFpcr(), + else => unreachable, + }; +} + +/// Restore a word previously returned by `save`, on the same thread. +pub fn restore(word: Raw) void { + if (!controllable) return; + switch (builtin.cpu.arch) { + .x86_64 => writeMxcsr(@truncate(word)), + .aarch64, .aarch64_be => writeFpcr(word), + else => unreachable, + } +} + +/// Install an arbitrary `state` on the CALLING thread. +/// +/// Narrow by construction: it rewrites only the three fields `State` names and +/// preserves every other bit of the control word — exception masks and sticky +/// exception flags survive, which is why it is a read-modify-write and not a +/// constant store. +/// +/// On AArch64 `flush_to_zero` and `denormals_are_zero` are ONE bit (`FPCR.FZ`). +/// A state that sets them differently is not representable there, and the +/// disagreement is resolved toward flushing — the conservative direction, since +/// it makes `read` report back exactly what was asked for on at least one of +/// the two fields rather than silently dropping both. +pub fn installState(state: State) void { + if (!controllable) return; + + switch (builtin.cpu.arch) { + .x86_64 => { + var word = readMxcsr(); + word &= ~(mxcsr_rc_mask | mxcsr_ftz_bit | mxcsr_daz_bit); + word |= @as(u32, switch (state.rounding) { + .nearest_even => 0b00, + .toward_negative => 0b01, + .toward_positive => 0b10, + .toward_zero => 0b11, + }) << mxcsr_rc_shift; + if (state.flush_to_zero) word |= mxcsr_ftz_bit; + if (state.denormals_are_zero) word |= mxcsr_daz_bit; + writeMxcsr(word); + }, + .aarch64, .aarch64_be => { + var word = readFpcr(); + word &= ~(fpcr_rmode_mask | fpcr_fz_bit); + word |= @as(u64, switch (state.rounding) { + .nearest_even => 0b00, + .toward_positive => 0b01, + .toward_negative => 0b10, + .toward_zero => 0b11, + }) << fpcr_rmode_shift; + if (state.flush_to_zero or state.denormals_are_zero) word |= fpcr_fz_bit; + writeFpcr(word); + }, + else => unreachable, + } +} + +/// Install `engine_default` on the CALLING thread. +/// +/// **THE RULE IS A PREDICATE OVER SITES, NOT A LIST OF THEM** (`ARCH-031` rule 5): +/// every point where a thread begins and every point where a process begins +/// installs, wherever in the tree it lives. Tier, module and purpose do not +/// enter — see below. +/// +/// This doc comment used to enumerate three sites and declare that «a fourth one +/// inside a module is a defect rather than an extension». **That text was false +/// by M1.1.14, and it is the reason the enumeration is gone rather than +/// updated.** Two failures, not one: the set had grown past three, and the rule +/// it stated was itself wrong — the repository now installs inside modules by +/// design (the shader watcher's thread, the determinism entry), because a thread +/// born inside a module is a thread all the same. A count in prose drifts exactly +/// as the corpus's own enumeration of this rule's sites drifted, which M1.1.14 +/// measured; replacing the count with a larger count would only reset the clock +/// on the same defect. +/// +/// **NO EXCEPTION FOR A PROGRAM THAT COMPARES NOTHING TODAY.** A bench over +/// integer kernels installs too. The exemption is tempting and does not survive: +/// it is a judgement about what a program will always be, a program that +/// compared nothing becomes a fixture, and a float kernel run under an unowned +/// environment measures a configuration that exists on no machine. The cost of +/// installing is two instructions. +/// +/// The set is DERIVED and journalled rather than asserted here — `briefs/` +/// m1.1.14 Closing notes carries it with the recipe to re-derive it from source, +/// because a reader who can re-derive does not have to trust. The main thread +/// needs an explicit site because it is not born of a spawn, and +/// `core/platform/threading.zig` — FROZEN at C0.5 — propagates `std.Thread` +/// as-is rather than wrapping it, so the engine has no single `spawn_thread` of +/// its own to hook. When a platform-layer `init()` lands, the process-entry +/// sites move into it and nothing else changes. +/// +/// Idempotent — which is what lets a site be added without auditing whether an +/// ancestor already installed. +pub fn install() void { + installState(engine_default); +} + +/// Whether the calling thread carries `engine_default`. +/// +/// `true` on a target with no readable control register: the question is not +/// answerable there, and a module entry point must not refuse to run because a +/// port has no `MXCSR`. `controllable` is the field that says which of the two +/// `true`s this is, and the deterministic CI cells are all on the readable side. +pub fn isEngineDefault() bool { + const state = read() orelse return true; + return state.eql(engine_default); +} + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; + +test "float_env: the engine default is what a fresh thread already carries" { + // Not a tautology, and it is the load-bearing observation of the whole + // deliverable: on the three targets the engine ships, the OS hands out a + // thread already in round-to-nearest with denormals live. `install` is + // therefore a BELT — its value is against a process where something else + // (a driver, a linked C runtime, a plugin) has already moved the register. + // If this test ever fails, the assertion at the physics entry point becomes + // the load-bearing half instead, and that is worth knowing loudly. + if (!controllable) return error.SkipZigTest; + try testing.expect(isEngineDefault()); +} + +test "float_env: install is idempotent and preserves the bits it does not own" { + if (!controllable) return error.SkipZigTest; + + install(); + try testing.expect(isEngineDefault()); + install(); + try testing.expect(isEngineDefault()); +} + +test "float_env: installState round-trips every state read can report" { + // Writer against reader, over the WHOLE domain rather than one sample. It + // is the write-direction half of the encoding swap the `Rounding` doc + // comment describes: the read test below pins the decode, this pins the + // encode, and a table copied from one architecture to the other fails here + // on two of the four rows. + // + // `denormals_are_zero` is swept only together with `flush_to_zero`, because + // AArch64 has one bit for the two and a split state is unrepresentable + // there. The x86-only split state has its own assertion in the read test. + if (!controllable) return error.SkipZigTest; + + const saved = save(); + defer restore(saved); + + for ([_]Rounding{ .nearest_even, .toward_negative, .toward_positive, .toward_zero }) |r| { + for ([_]bool{ false, true }) |flush| { + const want: State = .{ + .rounding = r, + .flush_to_zero = flush, + .denormals_are_zero = flush, + }; + installState(want); + try testing.expect(read().?.eql(want)); + } + } + + // And `install` is exactly `installState(engine_default)` — pinned so the + // convenience wrapper cannot drift away from the state it claims to set. + installState(.{ .rounding = .toward_zero, .flush_to_zero = true, .denormals_are_zero = true }); + install(); + try testing.expect(read().?.eql(engine_default)); +} + +test "float_env: read observes a perturbed ROUNDING MODE" { + // The discrimination guard the two tests above depend on: without a probe + // that actually moves the register, `isEngineDefault` returning true would + // be satisfied by a reader that always answers `engine_default` + // (`engine-development-workflow.md` §5.5 — an oracle is confronted with a + // change of the OBJECT). One field per block: this one owns the rounding + // decode, and it is the field whose ENCODING differs between the two + // architectures, so a decode table copied from one to the other fails here. + if (!controllable) return error.SkipZigTest; + + const saved = save(); + defer restore(saved); + + installState(.{ .rounding = .toward_zero, .flush_to_zero = false, .denormals_are_zero = false }); + try testing.expectEqual(Rounding.toward_zero, read().?.rounding); + try testing.expect(!isEngineDefault()); + + installState(.{ .rounding = .toward_positive, .flush_to_zero = false, .denormals_are_zero = false }); + try testing.expectEqual(Rounding.toward_positive, read().?.rounding); + try testing.expect(!isEngineDefault()); +} + +test "float_env: read observes a perturbed FLUSH-TO-ZERO" { + // Second field, second block. Folded into the rounding test, an + // implementation that compared only the rounding mode would still pass on + // its other rows and the verdict would name the wrong field. + if (!controllable) return error.SkipZigTest; + + const saved = save(); + defer restore(saved); + + installState(.{ .rounding = .nearest_even, .flush_to_zero = true, .denormals_are_zero = true }); + try testing.expectEqual(Rounding.nearest_even, read().?.rounding); + try testing.expect(read().?.flush_to_zero); + try testing.expect(!isEngineDefault()); +} + +test "float_env: x86_64 keeps DAZ and FTZ representable apart" { + // The x86-only state that is the whole reason `State` carries two fields + // where AArch64 has one bit. A third-party driver really does leave this + // behind, and collapsing the two would make it unrepresentable — so the + // claim is asserted on the architecture that has it rather than described + // in a comment on the architecture that does not. + if (builtin.cpu.arch != .x86_64) return error.SkipZigTest; + + const saved = save(); + defer restore(saved); + + installState(.{ .rounding = .nearest_even, .flush_to_zero = false, .denormals_are_zero = true }); + try testing.expect(read().?.denormals_are_zero); + try testing.expect(!read().?.flush_to_zero); + try testing.expect(!isEngineDefault()); +} + +test "float_env: install repairs any perturbation it is handed" { + // The restore half, separated because it is a claim about the WRITER while + // the three blocks above are claims about the READER. A `save`/`restore` + // pair that worked while `install` did not would leave every one of them + // green. + if (!controllable) return error.SkipZigTest; + + const saved = save(); + defer restore(saved); + + installState(.{ .rounding = .toward_zero, .flush_to_zero = true, .denormals_are_zero = true }); + try testing.expect(!isEngineDefault()); + install(); + try testing.expect(isEngineDefault()); +} + +/// The smallest normal `f64` halved — a DENORMAL — computed so that no +/// optimizer can answer it without asking the FPU. +/// +/// Both operands are loaded through a `volatile` pointer, the product is stored +/// through one, and the result is re-loaded through it. That is not belt and +/// braces: `std.mem.doNotOptimizeAway(&x)` is NOT sufficient here, and this is +/// measured rather than feared. On the `x86_64` / ReleaseSafe build — which is +/// a CI cell — the weaker form yielded the folded denormal for one use of the +/// expression and the flushed zero for a comparison against `0.0` in the SAME +/// statement, i.e. two different values for one product. A probe that reports +/// two answers cannot witness one bit. +fn denormalProduct() f64 { + var tiny: f64 = std.math.floatMin(f64); + var half: f64 = 0.5; + var out: f64 = 0; + const p_tiny: *volatile f64 = &tiny; + const p_half: *volatile f64 = ½ + const p_out: *volatile f64 = &out; + p_out.* = p_tiny.* * p_half.*; + return p_out.*; +} + +test "float_env: a flushed denormal is observable arithmetic, not just a bit" { + // What the register bit MEANS, measured rather than taken from the manual. + // The whole contract rests on the claim that flushing denormals changes a + // computed value; this is the one place that claim is exercised end to end. + // Without it, `flush_to_zero` is a boolean nobody has ever tied to a number. + if (!controllable) return error.SkipZigTest; + + // With denormals preserved, the smallest normal halved is a denormal and is + // NOT zero. This is also the POSITIVE witness of the assertion below: "the + // product is zero once flushing is on" is satisfied by a probe that always + // returns zero (workflow §5.5). + try testing.expect(denormalProduct() != 0.0); + + switch (builtin.cpu.arch) { + .x86_64 => { + const saved = readMxcsr(); + defer writeMxcsr(saved); + writeMxcsr(saved | mxcsr_ftz_bit | mxcsr_daz_bit); + try testing.expectEqual(@as(f64, 0.0), denormalProduct()); + }, + .aarch64, .aarch64_be => { + const saved = readFpcr(); + defer writeFpcr(saved); + writeFpcr(saved | fpcr_fz_bit); + try testing.expectEqual(@as(f64, 0.0), denormalProduct()); + }, + else => unreachable, + } + + install(); + try testing.expect(denormalProduct() != 0.0); +} diff --git a/src/foundation/math/math.zig b/src/foundation/math/math.zig index 7506b7f1..e9082b67 100644 --- a/src/foundation/math/math.zig +++ b/src/foundation/math/math.zig @@ -13,6 +13,8 @@ const vec = @import("vec.zig"); const quat = @import("quat.zig"); const mat3 = @import("mat3.zig"); const aabb = @import("aabb.zig"); +const trig = @import("trig.zig"); +const reduce_mod = @import("reduce.zig"); /// Generic vector constructor `Vec(N, T)`. pub const Vec = vec.Vec; @@ -47,6 +49,12 @@ pub const triangleCrossDirection = @import("exact.zig").triangleCrossDirection; /// The power-of-two exponent that reduces three points below unit magnitude. pub const pow2ReductionExponent = vec.pow2ReductionExponent; +/// Ordered lane reductions — the sanctioned form for FLOAT vectors, and the reason `@reduce` may +/// not be used on them. `@reduce` delegates the reduction order to the backend by construction, and +/// two Zig 0.16 backends were measured at M1.1.14 to disagree on the same `x86_64` target. See +/// `reduce.zig` for the two disassemblies and `ARCH-031` rule 3 for the contract. +pub const reduce = reduce_mod; + /// Generic quaternion constructor `Quat(T)`. pub const Quat = quat.Quat; /// f32 quaternion. @@ -62,6 +70,27 @@ pub const Aabb = aabb.Aabb; /// f32 axis-aligned bounding box. pub const Aabbf = aabb.Aabbf; +/// The floating-point EXECUTION state (`ARCH-031` rule 5) — rounding mode and +/// denormal handling of the calling thread. Installed by Tier 0 at thread +/// creation and at process entry, ASSERTED by every module whose output is +/// compared. +/// +/// It lives under `math/` rather than beside it because `engine-phase-1-criteria.md` +/// C1.1 binds `forge_3d` to a whitelist of two dependencies — `foundation/math/` +/// and `forge/api/` — and `forge_3d` is the module that has to assert. There is +/// no facade in `core/platform/`: the tier rule is documented at the definition, +/// not re-exported across the boundary. Added M1.1.14. +pub const float_env = @import("float_env.zig"); + +/// DETERMINISTIC cosine — no libm call, fixed operation order (`ARCH-031` +/// rule 4). NOT a wrapper around `@cos`: `@cos` lowers to an external `cosf` / +/// `cos` on every target the engine ships, and two C libraries disagree by an +/// ULP. Domain-bounded by `max_argument`; see `trig.zig` for what that bound is +/// and why serving past it would mean writing a replacement libm. +pub const cos = trig.cos; +/// The largest argument magnitude `cos` accepts, in radians. +pub const max_trig_argument = trig.max_argument; + // Pins so the inline tests in every sub-file are analysed when this module is // built as a test target (engine-zig-conventions.md §13). comptime { @@ -69,4 +98,11 @@ comptime { _ = quat; _ = mat3; _ = aabb; + _ = trig; + _ = float_env; + // M1.1.14 — `exact.zig` is reached elsewhere only through `triangleIsFlat` and + // `triangleCrossDirection`, and referencing a DECL analyses that decl, never the + // file's `test` blocks. Measured: its two tests had never run — the exact integer + // arithmetic M1.1.11.1 spent eleven rounds establishing was unguarded. + _ = @import("exact.zig"); } diff --git a/src/foundation/math/reduce.zig b/src/foundation/math/reduce.zig new file mode 100644 index 00000000..5b607efb --- /dev/null +++ b/src/foundation/math/reduce.zig @@ -0,0 +1,233 @@ +//! Ordered lane reductions — the sanctioned form for FLOAT vectors, and the +//! reason `@reduce` may not be used on them. +//! +//! `ARCH-031` rule 3 fixes the reduction order of every float reduction on a +//! compared path, and `@reduce` does not carry it TODAY — not because the +//! builtin is underspecified, but because one of the two code generators Weld +//! builds with does not honour what it specifies. +//! +//! THE LANGUAGE IS ON OUR SIDE, and this was checked rather than assumed. The +//! langref's `@reduce` entry reads "performing a sequential horizontal reduction +//! of its elements", and: "when applied on floating point types the operation +//! associativity is preserved, unless the float mode is set to `Optimized`." +//! That is an order, stated. (Read at tag `0.15.2`; `0.16.0` carries no public +//! tag on `ziglang/zig`, so the exact 0.16 wording is not citable and this is +//! the nearest tagged text.) +//! +//! WHAT ONE BACKEND DOES WITH IT — measured at M1.1.14 by disassembling both at +//! `-mcpu=baseline`, `x86_64-linux`, on a 3-lane f32 `@reduce(.Add, …)` whose +//! product vector is `p`: +//! +//! - LLVM (`stage2_llvm`), at BOTH `-O Debug` and `-O ReleaseSafe`: +//! `movaps` seeds `p₀`, `shufps $0x55` brings `p₁`, `unpckhpd` brings `p₂`. +//! That is `(p₀ + p₁) + p₂`. +//! - The self-hosted backend (`stage2_x86_64`, which serves `x86_64-linux` +//! in Debug): `movhlps` extracts `p₂`, `addss` adds `p₀`, `shufps $0x1` +//! extracts `p₁`, `addss`. That is `p₁ + (p₂ + p₀)`. +//! +//! Float addition is not associative, so those are two different functions: +//! they disagree on 313816 of 1000000 random f32 triples in `[-50, 50]` +//! — 31.4%, by one ULP. In the determinism harness that showed up as the +//! continuous state diverging at frame 1 and amplifying to 2.53e-3 m by frame +//! 38 of a 60-frame window. +//! +//! `p₁ + (p₂ + p₀)` is neither sequential in lane order nor an +//! associativity-preserving rendering of the sequential fold, so it contradicts +//! both halves of the specified sentence. It is emitted identically under the +//! DEFAULT float mode and under an explicit `@setFloatMode(.strict)`, at `-O +//! Debug`, `ReleaseSafe` and `ReleaseFast` — instruction for instruction, all +//! six listings. **That is a Zig compiler defect, and it is owed upstream.** +//! +//! WHY THE FOLD SHIPS ANYWAY, and this is the whole point rather than a +//! consolation: bit-exactness must not rest on a compiler fix, including one +//! that arrives. A source-level fold is the same function under every backend, +//! every version and every float mode, and it stops being a question the day it +//! is written. `@reduce` would put the engine's determinism back under someone +//! else's release schedule. +//! +//! **ORDERED IS NOT THE PROPERTY.** `p₁ + (p₂ + p₀)` is perfectly ordered, and +//! it is not the total reduction (`faddv`, `haddps`) one would think to watch +//! for. The property is THE SAME ORDER EVERYWHERE, which only a fold written in +//! source can carry. A pair-wise instruction remains perfectly admissible when +//! it REALISES that source fold — `faddp` on AArch64 does — because adding two +//! lanes with one instruction is the same IEEE addition as adding them with two. +//! What is inadmissible is leaving the choice to the code generator. +//! +//! The f64 half of that measurement is the control, and it is why the harness +//! did not diverge at f64: there both backends produce `(p₀ + p₁) + p₂`, LLVM +//! by `mulpd`+`unpckhpd` and the self-hosted backend by a sequential `addsd` +//! loop over memory. Same order, same bits, 1000 frames — the agreement was an +//! accident of two independent choices, never a guarantee. +//! +//! These folds are ASCENDING BY LANE INDEX, left-associated. The order is +//! arbitrary in the sense that any fixed order would serve; it is not arbitrary +//! in the sense that it is now the engine's, and changing it invalidates every +//! committed determinism witness. +//! +//! `.Min` and `.Max` are here for a second reason on top of order: `@reduce` +//! leaves NaN propagation to the backend as well, whereas `@max`/`@min` are +//! specified by the language to return the non-NaN operand. A fold of `@max` is +//! therefore NaN-ignoring BY SPECIFICATION rather than by observed lowering — +//! which matters, because `triangleCross` depends on that behaviour to select +//! its tiers. +//! +//! Integer and boolean reductions are NOT covered and need no helper: integer +//! addition is associative and exact, and `.And`/`.Or`/`.Xor` are order-free. +//! `@reduce` stays the right tool there, and the lint rule `no_float_reduce` +//! recognises such a site by the `WELD_INTEGER_LANES` marker it must carry. + +const std = @import("std"); + +/// The scalar a vector type's lanes hold. +fn Lane(comptime V: type) type { + const info = @typeInfo(V); + if (info != .vector) @compileError("expected a vector type, got " ++ @typeName(V)); + return info.vector.child; +} + +/// How many lanes a vector type has. +fn lanes(comptime V: type) comptime_int { + return @typeInfo(V).vector.len; +} + +/// Compile-time refusal of anything but a float vector. These folds exist for +/// float determinism; an integer caller reaching them would pay a scalar chain +/// for an exactness it already had, and would hide from the lint rule the one +/// site where a reader should check the claim. +fn assertFloatVector(comptime V: type) void { + const child = Lane(V); + if (@typeInfo(child) != .float) { + @compileError("foundation/math/reduce is for FLOAT vectors; " ++ + @typeName(child) ++ " lanes are exact under any order — use `@reduce` " ++ + "with a `WELD_INTEGER_LANES` marker"); + } + if (lanes(V) == 0) @compileError("a zero-lane reduction has no value to return"); +} + +/// Sum of the lanes, folded left in ascending lane order: `((v₀ + v₁) + v₂) + …`. +pub fn foldAdd(v: anytype) Lane(@TypeOf(v)) { + const V = @TypeOf(v); + comptime assertFloatVector(V); + var acc = v[0]; + comptime var i: usize = 1; + inline while (i < lanes(V)) : (i += 1) acc += v[i]; + return acc; +} + +/// Product of the lanes, folded left in ascending lane order: `((v₀ · v₁) · v₂) · …`. +pub fn foldMul(v: anytype) Lane(@TypeOf(v)) { + const V = @TypeOf(v); + comptime assertFloatVector(V); + var acc = v[0]; + comptime var i: usize = 1; + inline while (i < lanes(V)) : (i += 1) acc *= v[i]; + return acc; +} + +/// Largest lane, folded left in ascending lane order. NaN lanes are IGNORED — +/// `@max` is specified to return the non-NaN operand — so the result is NaN only +/// if every lane is NaN. +pub fn foldMax(v: anytype) Lane(@TypeOf(v)) { + const V = @TypeOf(v); + comptime assertFloatVector(V); + var acc = v[0]; + comptime var i: usize = 1; + inline while (i < lanes(V)) : (i += 1) acc = @max(acc, v[i]); + return acc; +} + +/// Smallest lane, folded left in ascending lane order. NaN lanes are IGNORED, +/// mirror of `foldMax`. +pub fn foldMin(v: anytype) Lane(@TypeOf(v)) { + const V = @TypeOf(v); + comptime assertFloatVector(V); + var acc = v[0]; + comptime var i: usize = 1; + inline while (i < lanes(V)) : (i += 1) acc = @min(acc, v[i]); + return acc; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "foldAdd is the left fold, and the test can tell the two orders apart" { + // The discriminating triple: the two orders the two backends were measured + // to emit disagree here by exactly one ULP. A test written on a triple where + // they agree would pass against either implementation and prove nothing, so + // the guard below asserts the triple discriminates BEFORE the claim is made. + const p: @Vector(3, f32) = .{ -12.127813, 21.12078, -40.04462 }; + const left: f32 = (p[0] + p[1]) + p[2]; + const other: f32 = p[1] + (p[2] + p[0]); + try std.testing.expect(@as(u32, @bitCast(left)) != @as(u32, @bitCast(other))); + + try std.testing.expectEqual(@as(u32, @bitCast(left)), @as(u32, @bitCast(foldAdd(p)))); +} + +test "foldAdd folds ascending for four lanes too" { + // `1e17` has an ULP of 16 at f64, so a partial sum of 2 vanishes into it and + // the two ones survive or not depending purely on WHEN they are added. + // Left: ((1 + 1) + 1e17) − 1e17 = 0, the pair absorbed before the cancellation. + // Right: 1 + (1 + (1e17 − 1e17)) = 2, the cancellation happening first. + const p: @Vector(4, f64) = .{ 1.0, 1.0, 1e17, -1e17 }; + const expected: f64 = ((p[0] + p[1]) + p[2]) + p[3]; + const right: f64 = p[0] + (p[1] + (p[2] + p[3])); + // Non-vacuity FIRST: a quadruple on which the two folds agree would pass + // against a right-folding implementation and prove nothing. The first + // quadruple written here was exactly that, and this guard is what caught it. + try std.testing.expect(@as(u64, @bitCast(right)) != @as(u64, @bitCast(expected))); + try std.testing.expectEqual(@as(f64, 0.0), expected); + try std.testing.expectEqual(@as(f64, 2.0), right); + + try std.testing.expectEqual( + @as(u64, @bitCast(expected)), + @as(u64, @bitCast(foldAdd(p))), + ); +} + +test "foldMul is the left fold, on a product that rounds" { + const p: @Vector(3, f32) = .{ 1.0000001, 3.0000002, 7.0000005 }; + const left: f32 = (p[0] * p[1]) * p[2]; + try std.testing.expectEqual(@as(u32, @bitCast(left)), @as(u32, @bitCast(foldMul(p)))); +} + +test "foldMax and foldMin agree with the scalar answer" { + const p: @Vector(4, f32) = .{ -3.5, 12.25, 0.0, -40.0 }; + try std.testing.expectEqual(@as(f32, 12.25), foldMax(p)); + try std.testing.expectEqual(@as(f32, -40.0), foldMin(p)); +} + +test "foldMax ignores NaN lanes unless every lane is NaN" { + const nan = std.math.nan(f32); + // The case `triangleCross` depends on: one lane NaN from `inf − inf`, the + // other two finite and non-zero. The largest must be the finite maximum. + const mixed: @Vector(3, f32) = .{ 2.81e14, nan, -2.81e14 }; + try std.testing.expectEqual(@as(f32, 2.81e14), foldMax(mixed)); + try std.testing.expectEqual(@as(f32, -2.81e14), foldMin(mixed)); + + // A NaN in the FIRST lane seeds the accumulator, so this direction is the + // one an implementation seeded with `v[0]` could get wrong. + const leading: @Vector(3, f32) = .{ nan, 5.0, -5.0 }; + try std.testing.expectEqual(@as(f32, 5.0), foldMax(leading)); + try std.testing.expectEqual(@as(f32, -5.0), foldMin(leading)); + + const all_nan: @Vector(3, f32) = .{ nan, nan, nan }; + try std.testing.expect(std.math.isNan(foldMax(all_nan))); + try std.testing.expect(std.math.isNan(foldMin(all_nan))); +} + +test "the folds are comptime-evaluable" { + const p: @Vector(3, f64) = .{ 1.0, 2.0, 4.0 }; + const sum = comptime foldAdd(p); + const prod = comptime foldMul(p); + try std.testing.expectEqual(@as(f64, 7.0), sum); + try std.testing.expectEqual(@as(f64, 8.0), prod); +} + +test "a single-lane vector folds to its only lane" { + const p: @Vector(1, f32) = .{-7.5}; + try std.testing.expectEqual(@as(f32, -7.5), foldAdd(p)); + try std.testing.expectEqual(@as(f32, -7.5), foldMul(p)); + try std.testing.expectEqual(@as(f32, -7.5), foldMax(p)); + try std.testing.expectEqual(@as(f32, -7.5), foldMin(p)); +} diff --git a/src/foundation/math/trig.zig b/src/foundation/math/trig.zig new file mode 100644 index 00000000..6f3a4d33 --- /dev/null +++ b/src/foundation/math/trig.zig @@ -0,0 +1,474 @@ +//! `foundation/math/trig.zig` — the engine's DETERMINISTIC trigonometry. +//! +//! `ARCH-031` rule 4 forbids a system-libm transcendental on any path whose +//! output is compared: two C libraries disagree by an ULP, and a constant +//! derived from one of them is divergent engine state. The substitute is an +//! in-house implementation at a FIXED operation order, added **function by +//! function on demonstrated need** — never as a replacement libm. This file +//! carries exactly one function, and M1.1.14 added it for exactly one call +//! site: the `max_slope` conversion of the kinematic character controller +//! (`engine-physics-queries.md` §1.12.5), whose result is STORED engine state +//! and therefore sits in the compared bits from frame 0. +//! +//! `@sqrt` is deliberately absent from this file and stays a builtin: IEEE-754 +//! requires it correctly rounded and it lowers to a hardware instruction +//! (`ARCH-031` rule 4, last sentence). +//! +//! **What makes the result reproducible**, in three parts, none of which is a +//! preference: +//! +//! 1. **Fixed operation order.** Every expression below is written out; no +//! reassociation is admitted, and `.strict` — the language default, never +//! disabled here — is what forbids the backend from finding one. There is +//! no `@mulAdd`: a contraction would change the result by an ULP on the +//! ISA that has the instruction and not on the one that does not, which is +//! precisely the divergence this file exists to remove (`ARCH-031` rule 2). +//! 2. **No external symbol.** `+`, `-`, `*` and `@abs` only. In particular +//! neither `@floor` nor `@round` appears: on a baseline x86_64 target +//! (no SSE4.1) `@floor` lowers to an external `floor`, and while `floor` +//! is exactly specified by IEEE-754 and would therefore not diverge, an +//! external libm symbol on this path would still have to be argued about +//! at every future assembly inventory. The integer part is extracted by +//! the magic-constant add instead (below). +//! 3. **A rounding mode that is installed, not assumed.** The reduction's +//! `roundToNearestEven` step IS the magic-constant add `(v + 2^52) - 2^52`, +//! which computes round-to-nearest-even only BECAUSE the FPU is in that +//! mode. Under round-toward-zero the same expression returns the truncation +//! and this function silently returns a different number. That is not a +//! weakness of the trick — it is the reason `foundation/math/float_env.zig` +//! installs the mode at thread creation and every deterministic module +//! asserts it at its entry point (`ARCH-031` rule 5). The two deliverables +//! of this milestone hold each other up. + +const std = @import("std"); + +/// The largest argument magnitude `cos` accepts, `2^20 · π/2` ≈ `1.647e6` rad. +/// +/// The bound is the exactness limit of the argument reduction, not a taste: the +/// first Cody-Waite constant carries 33 significant bits, so the product +/// `n · pio2_hi` is EXACT while `|n| < 2^(53−33) = 2^20`, and `|n|` is +/// `|x| · 2/π` rounded. Past it the product rounds, the reduced argument stops +/// being the true remainder, and far past it the reduced argument is not even +/// small — so the polynomial would be evaluated far outside the interval it was +/// fitted on and could leave `[−1, 1]` entirely. +/// +/// Serving a larger domain correctly needs Payne-Hanek — a multi-hundred-bit +/// table of `2/π` — which is the "replacement libm" `ARCH-031` rule 4 names as +/// an abuse of its own clause. The bound is declared and asserted instead. It +/// is `262144` full turns; the one call site authors a slope angle in `[0, π/2]`. +pub const max_argument: f64 = 1048576.0 * @as(f64, std.math.pi) / 2.0; + +// --- Argument reduction constants ------------------------------------------- +// +// A three-part split of π/2. `pio2_hi` and `pio2_mid` are the leading 33 and 33 +// bits of π/2 with their low mantissa bits zeroed, so `n · pio2_hi` and +// `n · pio2_mid` are EXACT products for the `|n| < 2^20` this file admits; the +// residue lives in `pio2_lo`. The three terms are subtracted in decreasing +// magnitude, which is what keeps the cancellation benign: `x − n · pio2_hi` is +// a difference of two nearly equal quantities and is therefore exact by +// Sterbenz, and each following term only refines it. +// +// All three terms are applied UNCONDITIONALLY. A refinement loop that stops +// early on a "good enough" test would make the operation count a function of +// the input, which is a shape this file does not want on a compared path even +// though it would be deterministic. +const pio2_hi: f64 = 1.57079632673412561417e+00; +const pio2_mid: f64 = 6.07710050650619224932e-11; +const pio2_lo: f64 = 2.02226624879595063154e-21; + +/// `2/π`, rounded to nearest. Only ever consumed to pick the quadrant index, so +/// its own rounding error is absorbed by the reduction that follows. +const two_over_pi: f64 = 6.36619772367581382433e-01; + +/// `2^52` — the magic constant whose add-then-subtract rounds an `f64` of +/// magnitude below `2^52` to the nearest integer, ties to even, in the current +/// rounding mode. See the file header, point 3. +const magic: f64 = 4503599627370496.0; + +// --- Kernel polynomials ------------------------------------------------------ +// +// Minimax coefficients for the two kernels on `|r| <= π/4`, the classical +// degree-13 sine and degree-14 cosine fits (Sun's fdlibm lineage, the same +// numbers every serious libm carries — they are the fit, not an implementation +// choice). Both kernels are well inside `f64` here: the sine residual is below +// `2⁻⁶¹` and the cosine's below `2⁻⁶³` on the interval, so the error budget of +// `cos` is dominated by the reduction, not by these. +// +// Evaluated by Horner, innermost coefficient first, in the written order. + +const s1: f64 = -1.66666666666666324348e-01; +const s2: f64 = 8.33333333332248946124e-03; +const s3: f64 = -1.98412698298579493134e-04; +const s4: f64 = 2.75573137070700676789e-06; +const s5: f64 = -2.50507602534068634195e-08; +const s6: f64 = 1.58969099521155010221e-10; + +const c1: f64 = 4.16666666666666019037e-02; +const c2: f64 = -1.38888888888741095749e-03; +const c3: f64 = 2.48015872894767294178e-05; +const c4: f64 = -2.75573143513906633035e-07; +const c5: f64 = 2.08757232129817482790e-09; +const c6: f64 = -1.13596475577881948265e-11; + +/// `sin(r)` for `|r| <= π/4`, as `r + r·z·P(z)` with `z = r²`. +/// +/// The leading `r` is kept OUT of the polynomial rather than folded into it: +/// for a small `r` — a denormal included — `z` underflows to zero, the whole +/// correction vanishes exactly, and the function returns `r` itself. A fit that +/// produced `r` from the polynomial would round it instead. +fn kernelSin(r: f64) f64 { + const z = r * r; + const p = s1 + z * (s2 + z * (s3 + z * (s4 + z * (s5 + z * s6)))); + return r + r * z * p; +} + +/// `cos(r)` for `|r| <= π/4`, as `1 − (0.5·z − z²·P(z))` with `z = r²`. +/// +/// The grouping is what keeps it accurate at the top of the interval: at +/// `r = π/4`, `0.5·z ≈ 0.308` against a result of `≈ 0.707`, so the subtraction +/// from `1` loses no leading bit. Written flat, `1 − 0.5·z + …` would round the +/// same way here but would stop saying why. +fn kernelCos(r: f64) f64 { + const z = r * r; + const p = c1 + z * (c2 + z * (c3 + z * (c4 + z * (c5 + z * c6)))); + return 1.0 - (0.5 * z - z * z * p); +} + +/// Cosine of `x` radians at scalar `T` (`f32` or `f64`), computed WITHOUT any +/// libm call and at a fixed operation order. +/// +/// The internal arithmetic is `f64` whatever `T` is. Widening an `f32` argument +/// is EXACT, so `cos(f32, x)` is `cos(f64, x)` narrowed once at the end — the +/// same discipline `engine-physics-queries.md` §1.12.6 states for the +/// displacement domain, and for the same reason: a verdict declared on what is +/// computed is only defined if the computation is. +/// +/// Domain: `x` finite and `@abs(x) <= max_argument`. Asserted, not clamped — a +/// silent clamp would make a caller's fault look like a modelling choice, which +/// is the arbitration this module takes everywhere else +/// (`engine-physics-queries.md` §1.12.6, on a saturated displacement). +pub fn cos(comptime T: type, x: T) T { + comptime std.debug.assert(T == f32 or T == f64); + + const wide: f64 = x; + // `@abs(NaN) <= c` is false, so the finiteness half of the domain is + // carried by the same comparison — the technique `query/root.zig` uses on + // its own domain asserts. + std.debug.assert(@abs(wide) <= max_argument); + + // Cosine is EVEN, so the sign folds away before anything else and the + // reduction only ever sees a non-negative argument. This is not an + // optimisation: it halves the number of paths the value table has to pin, + // and it makes the quadrant index non-negative by construction. + const a = @abs(wide); + + // Quadrant index. `magic` rounds to nearest-even (file header, point 3); + // `n` is then exact and, inside the declared domain, at most `2^20 + 1`. + const nf = (a * two_over_pi + magic) - magic; + const n: i64 = @intFromFloat(nf); + const quadrant: u2 = @intCast(n & 3); + + // Cody-Waite: `r = a − n·(π/2)` computed in three exact-then-refining + // steps. Order fixed, no early exit. + const r = ((a - nf * pio2_hi) - nf * pio2_mid) - nf * pio2_lo; + + const wide_result = switch (quadrant) { + 0 => kernelCos(r), + 1 => -kernelSin(r), + 2 => -kernelCos(r), + 3 => kernelSin(r), + }; + + return @floatCast(wide_result); +} + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; + +test "cos: cos(0) is exactly one, at both scalars" { + // The shortest path through the code: `nf = 0`, so the three reduction + // terms are exact zeros and `r` is `0`. Its own block because it is a + // different claim from evenness below — a reduction that broke at zero and + // a sign fold that broke are two defects, and one verdict for both would + // name neither. + try testing.expectEqual(@as(f64, 1.0), cos(f64, 0.0)); + try testing.expectEqual(@as(f32, 1.0), cos(f32, 0.0)); +} + +test "cos: evenness is EXACT, not approximate" { + // The sign is folded by `@abs` before any arithmetic runs, so the two calls + // execute bit-identical instructions. A fit that carried the sign through + // the polynomial would not give this, and the assertion is equality rather + // than a tolerance for exactly that reason. + const probes = [_]f64{ 0.3, 1.0, 2.0, 3.5, 100.25 }; + for (probes) |p| { + errdefer std.debug.print("cos: evenness failed at x = {d}\n", .{p}); + try testing.expectEqual(cos(f64, p), cos(f64, -p)); + try testing.expectEqual(cos(f32, @floatCast(p)), cos(f32, @floatCast(-p))); + } +} + +test "cos: a denormal argument returns exactly one" { + // The smallest positive `f64`. `z = r²` underflows to zero, the correction + // vanishes EXACTLY, and `1 − (0 − 0)` is `1`. The true cosine of `5e-324` + // differs from 1 by ~1e-647, far below the spacing of `f64` at 1, so `1` is + // the nearest representable value. This is also the case that would break + // first if the denormal were flushed to zero somewhere upstream, which is + // why the float environment is a sibling deliverable. + try testing.expectEqual(@as(f64, 1.0), cos(f64, std.math.floatTrueMin(f64))); + try testing.expectEqual(@as(f32, 1.0), cos(f32, std.math.floatTrueMin(f32))); +} + +test "cos: accuracy against the builtin, inside the declared domain" { + // NON-VACUITY GUARD for the committed value table. A table of expected bits + // proves the function is STABLE; it cannot prove it is a cosine — a + // function returning a constant would pass it. This test is the other half, + // and it is deliberately written against `@cos`, the very libm the module + // exists to remove: as an ORACLE it is fine (a test is not a compared path), + // and its own ULP-level platform variance is exactly why the tolerance here + // is a few ULP and the bit-exactness lives in the table instead. + // + // The sweep is oblique to the period on purpose. A sweep on multiples of + // π/2 would only ever exercise the quadrant boundaries, where the reduced + // argument is near zero and every implementation agrees. + const step: f64 = 0.31830988618379067; // 1/π — never lands on a quadrant edge + var i: u32 = 0; + var worst: f64 = 0; + while (i < 4096) : (i += 1) { + const x = @as(f64, @floatFromInt(i)) * step; + const got = cos(f64, x); + const want = @cos(x); + const err = @abs(got - want); + if (err > worst) worst = err; + } + // Absolute error, which for a function bounded by 1 is the meaningful + // grain. MEASURED on this sweep: `1.110e-16`, half the spacing of `f64` at + // unity. + // + // That is a measured maximum and NOT a claim of correct rounding, which it + // could not establish: the image of `cos` crosses zero, and near a zero the + // correctly rounded result demands an absolute error orders of magnitude + // below this one. The far-domain test below measures ~9.6 ULP on the same + // function, which would contradict such a claim outright. And accuracy is + // not the contract here — BINARY IDENTITY BETWEEN PLATFORMS is, and its + // oracle is the committed value table, compared bit for bit. At 1e-15 on a + // slope threshold this function is a dozen orders past what its one caller + // needs. + // + // The bound is set at 4 ULP rather than at the measurement because `@cos` + // is the oracle and its OWN answer moves by about an ULP between C + // libraries: a bound at the measured value would fail on a platform where + // the oracle drifted and the function did not. + try testing.expect(worst < 8.9e-16); +} + +test "cos: accuracy holds at the far end of the declared domain" { + // The reduction is what degrades with magnitude, so the accuracy claim is + // re-measured where it is weakest — just inside `max_argument`, where `|n|` + // is at its `2^20` exactness limit. Without this the accuracy test above + // would only ever have spoken for small arguments. + const near_max: f64 = max_argument - 0.5; + var i: u32 = 0; + var worst: f64 = 0; + while (i < 256) : (i += 1) { + const x = near_max - @as(f64, @floatFromInt(i)) * 0.37; + const err = @abs(cos(f64, x) - @cos(x)); + if (err > worst) worst = err; + } + // MEASURED: `2.123e-15`, ~9.6 ULP of unity — a factor 19 worse than at + // small arguments, which is the reduction's cost showing up exactly where + // the doc comment says it does and nowhere else. The bound keeps a ~5× + // margin over it. + try testing.expect(worst < 1.0e-14); +} + +test "cos: f32 result is the f64 result narrowed, not a separate computation" { + // Pins the widening discipline of the doc comment. If someone ever adds an + // `f32` fast path, this test is what refuses it — and refuses it for the + // right reason: two arithmetics for one function is two things to keep + // bit-identical across four platforms. + const probes = [_]f32{ 0.0, 0.125, 0.7853981, 1.0471975, 1.5707963, 3.1415927, 12345.678, 1.0e6 }; + for (probes) |p| { + try testing.expectEqual(@as(f32, @floatCast(cos(f64, @as(f64, p)))), cos(f32, p)); + } +} + +test "cos: quadrant selection is right at and around every boundary" { + // Each of the four quadrants, entered from both sides of its edge, with the + // reference values written as literals rather than derived from `@cos` — + // an oracle that shares the arithmetic it judges agrees with it (workflow + // §5.5). The tolerance is loose because the point is the QUADRANT, not the + // last bit: a swapped `kernelSin`/`kernelCos` or a rotated `switch` moves + // the answer by whole units, never by an ULP. + const half_pi: f64 = 1.5707963267948966; + const cases = [_]struct { x: f64, want: f64 }{ + .{ .x = 0.0, .want = 1.0 }, + .{ .x = half_pi, .want = 0.0 }, + .{ .x = 2.0 * half_pi, .want = -1.0 }, + .{ .x = 3.0 * half_pi, .want = 0.0 }, + .{ .x = 4.0 * half_pi, .want = 1.0 }, + .{ .x = 0.5 * half_pi, .want = 0.7071067811865476 }, + .{ .x = 1.5 * half_pi, .want = -0.7071067811865475 }, + .{ .x = 2.5 * half_pi, .want = -0.7071067811865477 }, + .{ .x = 3.5 * half_pi, .want = 0.7071067811865474 }, + }; + for (cases) |c| { + errdefer std.debug.print("cos: quadrant case failed at x = {d}\n", .{c.x}); + try testing.expectApproxEqAbs(c.want, cos(f64, c.x), 1.0e-15); + } +} + +test "cos: the range stays in [-1, 1] across the whole declared domain" { + // A polynomial evaluated outside the interval it was fitted on leaves the + // range long before it starts returning NaN, so this is the cheap detector + // for a reduction that has quietly stopped reducing. + var i: u32 = 0; + while (i < 20000) : (i += 1) { + // A stride that is not a rational multiple of π/2, walked to the domain + // edge, so the sweep visits every quadrant many times over. + const x = @as(f64, @floatFromInt(i)) * 82.35496; + try testing.expect(@abs(cos(f64, x)) <= 1.0); + } +} + +// --- P1-2: the committed bit table, and an oracle that is not `@cos` --------- +// +// WHY THIS EXISTS. The test above compares against `@cos`, and its own comment +// concedes what that can and cannot establish: it proves the function is STABLE, +// it cannot prove the function is a COSINE. Two implementations of the same wrong +// idea agree. M1.1.14's review named this: the milestone's first behavioural +// change was replacing `@cos` at the `max_slope` conversion, and nothing pinned +// the VALUE that replacement produces. +// +// THE ORACLE IS ARBITRARY-PRECISION AND EXTERNAL, and the recipe is here rather +// than the tool, because this repository is Zig and `tools/` holds Zig — the same +// arbitrage as the float-environment site list, and for the same reason: a reader +// who can RE-DERIVE does not have to trust. +// +// 1. pi to 80 digits by an alternating arctan series in exact decimal +// arithmetic — no floating point anywhere on the value path. +// 2. pi computed TWICE by different Machin-like formulas and required to agree +// to 70 digits: `16·atan(1/5) − 4·atan(1/239)` and +// `20·atan(1/7) + 8·atan(3/79)`. Two independent computations forced onto one +// number, the control this milestone credits above every other. +// Both give 3.14159265358979323846264338327950288419716939937510... +// 3. Each argument taken as its EXACT binary value — the f32 row is the cosine +// of the f32-rounding of the literal, not of the literal — reduced modulo +// 2·pi by an exact integer quotient, then the Taylor series for cosine. +// 4. The result rounded to the nearest representable value by comparing BOTH +// neighbours explicitly. A single `float()` conversion would inherit that +// conversion's rounding, and a Decimal → f64 → f32 path can double-round. +// +// TWO COLUMNS, TWO CLAIMS, AND THEY ARE NOT THE SAME CLAIM. `oracle` is the +// correctly-rounded true cosine and carries CORRECTNESS. `engine` is what this +// implementation emits and carries REPRODUCIBILITY — the change detector that must +// hold on all twelve CI cells. Presenting the second as evidence for the first is +// the defect family this milestone spent two sessions on, so it is said plainly: +// the `engine` column is self-generated and validates nothing about accuracy. +// +// THE BOUND IS ABSOLUTE AND NOT IN ULP, and that is a measurement rather than a +// preference. At the f64 nearest pi/2 the true cosine is 6.12e-17 — near-total +// cancellation — and the implementation's error there is 1.6e11 ULP of that value +// while being 2.0e-21 in ABSOLUTE terms, the SMALLEST absolute error in the whole +// table. A ULP bound would fail there, spectacularly, on the most accurate row. +// Cosine is bounded by one, so an absolute bound is its natural accuracy +// statement. +const CosCase = struct { + x: f64, + oracle_f32: u32, + oracle_f64: u64, + engine_f64: u64, +}; + +/// Twelve arguments, each present for a reason stated beside it. +/// +/// At **f32 the implementation is correctly rounded on all twelve** — measured, +/// so `oracle_f32` doubles as the reproducibility column there and no separate +/// engine column exists for it. At f64 nine of twelve agree exactly and three do +/// not; those three are what make the correctness bound do real work rather than +/// restate an equality (asserted below). +const cos_cases = [_]CosCase{ + .{ .x = 0.0, .oracle_f32 = 0x3F800000, .oracle_f64 = 0x3FF0000000000000, .engine_f64 = 0x3FF0000000000000 }, // `nf = 0`: the three reduction steps vanish + .{ .x = 0.785, .oracle_f32 = 0x3F351765, .oracle_f64 = 0x3FE6A2ECB934B59A, .engine_f64 = 0x3FE6A2ECB934B59A }, // THE ENGINE'S OWN VALUE — `CharacterDescriptor.max_slope`'s default + .{ .x = 0.5, .oracle_f32 = 0x3F60A940, .oracle_f64 = 0x3FEC1528065B7D50, .engine_f64 = 0x3FEC1528065B7D50 }, // quadrant 0, `kernelCos`, argument exact in both formats + .{ .x = 2.0, .oracle_f32 = 0xBED51133, .oracle_f64 = 0xBFDAA22657537205, .engine_f64 = 0xBFDAA22657537205 }, // quadrant 1, `-kernelSin` + .{ .x = 3.5, .oracle_f32 = 0xBF6FBBA0, .oracle_f64 = 0xBFEDF77403C11A5F, .engine_f64 = 0xBFEDF77403C11A5F }, // quadrant 2, `-kernelCos` + .{ .x = 5.0, .oracle_f32 = 0x3E913C2C, .oracle_f64 = 0x3FD22785706B4AD9, .engine_f64 = 0x3FD22785706B4ADA }, // quadrant 3, `kernelSin` — 0.17 eps off + .{ .x = -0.785, .oracle_f32 = 0x3F351765, .oracle_f64 = 0x3FE6A2ECB934B59A, .engine_f64 = 0x3FE6A2ECB934B59A }, // the evenness fold: identical to `+0.785` + .{ .x = 1.5707963267948966, .oracle_f32 = 0xB33BBD2E, .oracle_f64 = 0x3C91A62633145C07, .engine_f64 = 0x3C91A64C66245C07 }, // f64 nearest pi/2: near-total cancellation, 2.0e-21 absolute + .{ .x = 0.7853981633974483, .oracle_f32 = 0x3F3504F3, .oracle_f64 = 0x3FE6A09E667F3BCD, .engine_f64 = 0x3FE6A09E667F3BCD }, // f64 nearest pi/4: the kernel boundary + .{ .x = 1000.0, .oracle_f32 = 0x3F0FF813, .oracle_f64 = 0x3FE1FF026793F1BB, .engine_f64 = 0x3FE1FF026793F1BB }, // Cody-Waite over 636 quadrants + .{ .x = 100000.0, .oracle_f32 = 0xBF7FD61C, .oracle_f64 = 0xBFEFFAC3841B3DA7, .engine_f64 = 0xBFEFFAC3841B3DA7 }, // Cody-Waite over 63661 quadrants + .{ .x = 1647099.0, .oracle_f32 = 0x3F724189, .oracle_f64 = 0x3FEE4831257A62DA, .engine_f64 = 0x3FEE4831257A62D4 }, // the largest integer under `max_argument` — 3.14 eps, the worst row +}; + +/// Absolute error budget, in units of `floatEps(f64)`. Measured worst case 3.14, +/// at the argument nearest `max_argument`, where Cody-Waite has the least left to +/// work with. Four, so the bound is not a re-statement of the measurement — and +/// not forty, which would admit a real regression. +const cos_abs_budget: f64 = 4.0; + +test "cos: the committed bit table — reproducibility, on every cell" { + // THE DETERMINISM CLAIM, and the one whose failure on ONE of twelve CI cells is + // the finding this milestone exists to produce. Bit equality, no tolerance: a + // deterministic function either emits the same bits everywhere or it does not. + for (cos_cases) |c| { + const got32: u32 = @bitCast(cos(f32, @floatCast(c.x))); + errdefer std.debug.print("cos: f32 bits moved at x = {d}\n", .{c.x}); + try testing.expectEqual(c.oracle_f32, got32); + + const got64: u64 = @bitCast(cos(f64, c.x)); + errdefer std.debug.print("cos: f64 bits moved at x = {d}\n", .{c.x}); + try testing.expectEqual(c.engine_f64, got64); + } +} + +test "cos: it really is a cosine — against an oracle that never calls @cos" { + // THE CORRECTNESS CLAIM. The reference values come from exact decimal + // arithmetic outside this repository (recipe above); nothing on their path + // touched `@cos`, libm or Zig. + const eps = std.math.floatEps(f64); + for (cos_cases) |c| { + const want: f64 = @bitCast(c.oracle_f64); + const got = cos(f64, c.x); + const err = @abs(got - want); + errdefer std.debug.print( + "cos: at x = {d} the error is {d} eps, over the {d} eps budget\n", + .{ c.x, err / eps, cos_abs_budget }, + ); + try testing.expect(err <= cos_abs_budget * eps); + } + + // At f32 the claim is STRONGER and is asserted as such: correctly rounded, all + // twelve. That is why the table needs no separate f32 engine column — and if + // this ever fails while the f64 rows hold, the narrowing has moved, not the + // kernel. + for (cos_cases) |c| { + const want32: f32 = @bitCast(c.oracle_f32); + try testing.expectEqual(want32, cos(f32, @floatCast(c.x))); + } +} + +test "cos: the correctness bound is not a restatement of the bit table" { + // ANTI-VACUITY, and it is due: if `engine` equalled `oracle` on every row, the + // absolute bound above would be satisfied by construction and would measure + // nothing. It does not — three f64 rows differ, and the worst is 3.14 eps, + // which is 78% of the budget. So the bound is load-bearing on this table. + // + // Written as a property of the TABLE rather than as a count, so adding a row + // cannot silently make it vacuous: what is required is that some row exercise + // the bound, and that the worst row use a real fraction of it. + const eps = std.math.floatEps(f64); + var differing: usize = 0; + var worst: f64 = 0; + for (cos_cases) |c| { + if (c.oracle_f64 != c.engine_f64) differing += 1; + const want: f64 = @bitCast(c.oracle_f64); + const err = @abs(cos(f64, c.x) - want) / eps; + if (err > worst) worst = err; + } + try testing.expect(differing >= 1); + try testing.expect(worst > 1.0); + try testing.expect(worst <= cos_abs_budget); +} diff --git a/src/foundation/math/vec.zig b/src/foundation/math/vec.zig index a437e573..80ecc1d0 100644 --- a/src/foundation/math/vec.zig +++ b/src/foundation/math/vec.zig @@ -9,6 +9,7 @@ //! unit_z`, and rotating (1,0,0) by +90° about +Y yields (0,0,−1). const std = @import("std"); +const reduce = @import("reduce.zig"); const exact = @import("exact.zig"); /// Column vector of `N` components of scalar type `T`, backed by a @@ -74,7 +75,7 @@ pub fn Vec(comptime N: usize, comptime T: type) type { } /// Dot product `self · other`. pub fn dot(self: Self, other: Self) T { - return @reduce(.Add, self.data * other.data); + return reduce.foldAdd(self.data * other.data); } /// Cross product `self × other` (right-handed, `N == 3` only). pub fn cross(self: Self, other: Self) Self { @@ -91,7 +92,7 @@ pub fn Vec(comptime N: usize, comptime T: type) type { } /// Squared length `self · self` (no square root). pub fn lengthSq(self: Self) T { - return @reduce(.Add, self.data * self.data); + return reduce.foldAdd(self.data * self.data); } /// Euclidean length `sqrt(self · self)`. pub fn length(self: Self) T { @@ -153,7 +154,7 @@ pub fn Vec(comptime N: usize, comptime T: type) type { /// re-derives. The direction is unaffected: it is scale-free by construction, which is exactly /// why the two are returned separately rather than as one vector. pub fn unitAndLength(self: Self) ?struct { unit: Self, length: ?T } { - const largest = @reduce(.Max, @abs(self.data)); + const largest = reduce.foldMax(@abs(self.data)); if (largest == 0) return null; const reduced: Self = .{ .data = self.data / @as(Simd, @splat(largest)) }; const reduced_length = reduced.length(); @@ -166,7 +167,7 @@ pub fn Vec(comptime N: usize, comptime T: type) type { /// Largest absolute component. Zero exactly when every component is zero. pub fn maxAbsComponent(self: Self) T { - return @reduce(.Max, @abs(self.data)); + return reduce.foldMax(@abs(self.data)); } /// Multiply every component by `2^exp`, EXACTLY. @@ -378,8 +379,15 @@ pub fn CrossOutcome(comptime T: type) type { /// The test that moves between tiers is `isFinite` and exact zero — STRUCTURAL, never a tolerance, /// the same signal class the ray kernel reports as `.unrepresentable`. And it cannot be written on /// the largest component: one lane can come out NaN from `inf − inf` while the other two stay finite -/// and non-zero — `(2.81e14, NaN, −2.81e14)` is measured — and `@reduce(.Max, @abs(…))` lowers to a -/// NaN-IGNORING maximum, so a largest-component guard would let that NaN through. +/// and non-zero — `(2.81e14, NaN, −2.81e14)` is measured — while `maxAbsComponent` is NaN-IGNORING, +/// so a largest-component guard would let that NaN through. +/// +/// That NaN-ignoring behaviour is now GUARANTEED rather than observed, and the difference is the +/// point. Until M1.1.14 this paragraph read "`@reduce(.Max, @abs(…))` lowers to a NaN-ignoring +/// maximum" — a claim about a LOWERING, which is a property of whichever code generator happened to +/// be looked at and not of the language. `maxAbsComponent` folds `@max`, which the language +/// specifies to return the non-NaN operand, so the behaviour this tier selection depends on is now +/// owed by Zig and not lent by a backend. /// /// Tiers 2 and 3 are COLD by construction. Every vertex component must be finite. pub fn triangleCross( diff --git a/src/foundation/simd/bench/adler32_bench.zig b/src/foundation/simd/bench/adler32_bench.zig index 1b7987df..18a921ec 100644 --- a/src/foundation/simd/bench/adler32_bench.zig +++ b/src/foundation/simd/bench/adler32_bench.zig @@ -11,8 +11,18 @@ const std = @import("std"); const simd = @import("foundation").simd; +const float_env = @import("foundation").math.float_env; pub fn main(init: std.process.Init) !void { + // `ARCH-031` rule 5 — every PROCESS ENTRY POINT installs the float environment, + // with no exception for a program that compares nothing today. An exception of + // that shape is a judgement, and a judgement does not survive: a program that + // compared nothing becomes a fixture, and a float-kernel bench measured under + // an unpinned environment measures a configuration that exists nowhere. These + // two kernels are integer today, which makes the cost nil and changes nothing + // about the rule. + float_env.install(); + const gpa = init.gpa; const io = init.io; const args = try init.minimal.args.toSlice(init.arena.allocator()); diff --git a/src/foundation/simd/bench/paeth_bench.zig b/src/foundation/simd/bench/paeth_bench.zig index 9936f895..128fce14 100644 --- a/src/foundation/simd/bench/paeth_bench.zig +++ b/src/foundation/simd/bench/paeth_bench.zig @@ -7,8 +7,18 @@ const std = @import("std"); const simd = @import("foundation").simd; +const float_env = @import("foundation").math.float_env; pub fn main(init: std.process.Init) !void { + // `ARCH-031` rule 5 — every PROCESS ENTRY POINT installs the float environment, + // with no exception for a program that compares nothing today. An exception of + // that shape is a judgement, and a judgement does not survive: a program that + // compared nothing becomes a fixture, and a float-kernel bench measured under + // an unpinned environment measures a configuration that exists nowhere. These + // two kernels are integer today, which makes the cost nil and changes nothing + // about the rule. + float_env.install(); + const gpa = init.gpa; const io = init.io; const args = try init.minimal.args.toSlice(init.arena.allocator()); diff --git a/src/foundation/simd/kernels/adler32.zig b/src/foundation/simd/kernels/adler32.zig index ba14c771..47b338dc 100644 --- a/src/foundation/simd/kernels/adler32.zig +++ b/src/foundation/simd/kernels/adler32.zig @@ -60,8 +60,14 @@ pub fn vectorized(data: []const u8) u32 { while (i + vlen <= block_len) : (i += vlen) { const bytes: @Vector(vlen, u8) = block[i..][0..vlen].*; const widened: Vu32 = bytes; // element-wise u8 → u32 widening - const chunk_sum: u32 = @reduce(.Add, widened); - const lane_weighted: u32 = @reduce(.Add, widened * lanes); + // `widened` and `lanes` hold `u32`. Integer addition is associative and + // exact, so the backend's reduction order cannot change the result — the + // property `ARCH-031` rule 3 buys for floats is already had here, and a + // scalar fold would only cost a horizontal add in a checksum hot loop. + // The marker sits on each site rather than on this block: the `no_float_reduce` + // window is one line, deliberately, so the claim stays attached to the statement. + const chunk_sum: u32 = @reduce(.Add, widened); // WELD_INTEGER_LANES + const lane_weighted: u32 = @reduce(.Add, widened * lanes); // WELD_INTEGER_LANES // Global index j = i + lane, so Σ j·D = i·ΣD_chunk + Σ lane·D_chunk. sum_d += chunk_sum; sum_jd += @as(u64, i) * chunk_sum + lane_weighted; diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index e1e8fcee..fedc8562 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -2435,7 +2435,7 @@ pub fn closestPointOnCore( return .{ .distance = surface_distance, .position = core.b }; } const away = point.sub(core.b); - const scale = @reduce(.Max, @abs(away.data)); + const scale = away.maxAbsComponent(); if (scale == 0) { // The queried point coincides with the core witness. Reachable only in the // deep band, where the two are within float noise of each other; the diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 991c79d8..270c81e7 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1419,7 +1419,15 @@ pub const CharacterStore = struct { // The SINGLE trigonometric call of this module's whole life. Taken at `Real` on // the widened angle rather than in `f32` and widened after, so its accuracy is // bounded only by the angle the caller authored. - .cos_max_slope = @cos(@as(Real, desc.max_slope)), + // + // M1.1.14 — `math.cos`, NOT `@cos`. This is not a style change: `@cos` lowers + // to an external `cosf` on Linux, Windows and AArch64 alike, two C libraries + // disagree by an ULP, and the result here is STORED ENGINE STATE — it sits in + // the compared bits from frame 0 and never leaves them. It is, measured at + // recon, the only libm transcendental on the whole deterministic path, which is + // why removing it is a prerequisite of the bit-exactness contract rather than + // an improvement (`ARCH-031` rule 4, `engine-physics-queries.md` §1.12.5). + .cos_max_slope = math.cos(Real, @as(Real, desc.max_slope)), .padding = desc.padding, .predictive_contact_distance = desc.predictive_contact_distance, .collision_layer = desc.collision_layer, diff --git a/src/modules/forge/forge_3d/determinism.zig b/src/modules/forge/forge_3d/determinism.zig new file mode 100644 index 00000000..19796a5c --- /dev/null +++ b/src/modules/forge/forge_3d/determinism.zig @@ -0,0 +1,133 @@ +//! `forge_3d/determinism.zig` — the module's entry-point check on the floating- +//! point execution state (`ARCH-031` rule 5). +//! +//! `forge_3d` is a module whose output is compared between two machines, so the +//! invariant places an obligation on it that is one word long and easy to get +//! backwards: **assert, never install**. A module that re-installed the state at +//! its entry point would repair its own thread and leave every other consumer +//! of that thread — the renderer, the animation sampler, a plugin — running on +//! the state it just silently fixed, with no diagnostic anywhere. Installing +//! happens once per thread, at creation, and once per process, at entry +//! (`foundation/math/float_env.zig`, which states the RULE and deliberately +//! enumerates nothing — this sentence used to say "Tier 0's job" and "its three +//! call sites", and M1.1.14 measured both false: the set is ten and three of them +//! are inside modules, a thread born in a module being a thread all the same). +//! +//! **Where the entry point is, today and tomorrow.** There is no +//! `PhysicsWorld.step()` yet — the orchestration lands at M1.1.15. What exists +//! is a per-tick driver in the acceptance harness, and the determinism harness +//! of this milestone. Both call `assertFloatEnvironment` when they open a +//! world; `step()` inherits the same call and nothing else moves. Naming the +//! seam before its production consumer exists is the pattern this repository +//! already used for the `on_attach` dispatch seam at M1.0.6. +//! +//! Note that the check is not decorative even inside a single process: the job +//! system installs the environment on the threads it creates, and a `forge_3d` +//! world opened on a thread nobody installed — a test runner's main thread, a +//! tool, a future plugin host — is exactly the case this reports. + +const std = @import("std"); +const float_env = @import("foundation").math.float_env; + +/// The float-environment state of the calling thread, when it is NOT the state +/// the engine requires; `null` when the caller may proceed. +/// +/// Returning the offending state rather than a `bool` is deliberate: the point +/// of the check is to be able to SAY what is wrong, and "the float environment +/// is not the engine's" is not an actionable message. On a target with no +/// readable control register the answer is `null` — the question is not +/// answerable there and a module entry point must not refuse to run because a +/// port has no `MXCSR` (`foundation/math/float_env.zig`, `controllable`). +pub fn checkFloatEnvironment() ?float_env.State { + const state = float_env.read() orelse return null; + if (state.eql(float_env.engine_default)) return null; + return state; +} + +/// Assert that the calling thread carries the engine float environment. +/// +/// Compiled out in ReleaseFast like every other domain assert in this module +/// (`engine-physics-queries.md` §1.11.4 — *Domaine*). That is the established +/// arbitration here and not a weakening: the state is a property of the +/// PROCESS, established once at thread creation, so a Debug or ReleaseSafe run +/// of the same binary lineage catches it — unlike a per-call domain value, +/// which varies with the caller. +pub fn assertFloatEnvironment() void { + std.debug.assert(checkFloatEnvironment() == null); +} + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; + +test "determinism: the entry-point check passes on an engine thread" { + try testing.expectEqual(@as(?float_env.State, null), checkFloatEnvironment()); + assertFloatEnvironment(); +} + +test "determinism: a perturbed float state is DETECTED" { + if (!float_env.controllable) return error.SkipZigTest; + + // Perturbed through the OWNER's own writer, never by reaching for the + // control register here: this test is about `forge_3d`'s reaction, and a + // second copy of the register layout in a third file is exactly the drift + // `foundation/math/float_env.zig` is written to prevent. + const saved = float_env.save(); + defer float_env.restore(saved); + float_env.installState(.{ + .rounding = .toward_zero, + .flush_to_zero = false, + .denormals_are_zero = false, + }); + + const observed = checkFloatEnvironment(); + try testing.expect(observed != null); + try testing.expectEqual(float_env.Rounding.toward_zero, observed.?.rounding); +} + +test "determinism: a perturbed float state is NOT repaired by the check" { + // Separate block, and the separation is the point: detection and + // non-repair are two claims, and a check that quietly re-installed would + // satisfy the first while destroying the reason the second exists. Folded + // together, the verdict would not say which half broke. + if (!float_env.controllable) return error.SkipZigTest; + + const saved = float_env.save(); + defer float_env.restore(saved); + float_env.installState(.{ + .rounding = .toward_zero, + .flush_to_zero = false, + .denormals_are_zero = false, + }); + + // Read it three times. A self-healing check would repair on the first and + // report clean on the second. + try testing.expect(checkFloatEnvironment() != null); + try testing.expect(checkFloatEnvironment() != null); + try testing.expectEqual(float_env.Rounding.toward_zero, checkFloatEnvironment().?.rounding); + + // And Tier 0's installer is what puts it back — the division of labour + // stated in the file header, exercised rather than described. + float_env.install(); + try testing.expectEqual(@as(?float_env.State, null), checkFloatEnvironment()); +} + +test "determinism: flushed denormals are detected too, not only a rounding mode" { + // The check reads three fields; a test that only ever perturbs the rounding + // mode would pass against an implementation that compares one of them. This + // is that assertion's completeness half. + if (!float_env.controllable) return error.SkipZigTest; + + const saved = float_env.save(); + defer float_env.restore(saved); + float_env.installState(.{ + .rounding = .nearest_even, + .flush_to_zero = true, + .denormals_are_zero = true, + }); + + const observed = checkFloatEnvironment(); + try testing.expect(observed != null); + try testing.expectEqual(float_env.Rounding.nearest_even, observed.?.rounding); + try testing.expect(observed.?.flush_to_zero); +} diff --git a/src/modules/forge/forge_3d/determinism_main.zig b/src/modules/forge/forge_3d/determinism_main.zig new file mode 100644 index 00000000..e3ef231c --- /dev/null +++ b/src/modules/forge/forge_3d/determinism_main.zig @@ -0,0 +1,203 @@ +//! `zig build forge-determinism` — the instrument's command-line shell. +//! +//! DELIBERATELY THIN. Everything it reports is computed by `run.zig`, which is a +//! library precisely because two later milestones replay it (M1.1.25 at N +//! workers, M1.A on a rebuilt scheduler DAG). A harness whose logic lived in its +//! `main` would have to be re-entered through a process to be replayed. +//! +//! Usage: +//! forge-determinism run, compare against committed witnesses +//! forge-determinism --write-witness DIR generate the witnesses into DIR +//! +//! Regeneration is a DECLARED ACT — it is a separate flag, never a side effect of +//! a mismatch, and the brief requires it to be stated in the PR body with its +//! motive. A witness silently regenerated to make a cell green destroys exactly +//! the property it carries. +//! +//! **Why the entry sits HERE and not beside the harness it drives.** A Zig +//! module's import path is rooted at its root source file's directory, so an +//! executable rooted inside `tests/determinism/` cannot reach `config.zig` or +//! `root.zig` at all — measured, `error: import of file outside module path` on +//! every upward import. The harness proper stays in `tests/determinism/`; this +//! one file is the entry, and it has to live at the level its imports descend +//! from. + +const std = @import("std"); +const builtin = @import("builtin"); +const foundation = @import("foundation"); +const config = @import("config.zig"); +const run = @import("tests/determinism/run.zig"); +const trace = @import("tests/determinism/trace.zig"); +const witness = @import("tests/determinism/witness.zig"); + +// The two halves of a witness key live in `witness.zig`, beside the files they +// name. Holding a second copy here is how a key drifts from the artifact it is +// supposed to identify — the drift shape this repository has already paid for. +const precision_tag = witness.precision_tag; +const mode_tag = witness.mode_tag; + +fn writeFile(io: std.Io, dir_path: []const u8, name: []const u8, bytes: []const u8) !void { + var dir = try std.Io.Dir.cwd().openDir(io, dir_path, .{}); + defer dir.close(io); + var f = try dir.createFile(io, name, .{ .truncate = true }); + defer f.close(io); + var w = f.writer(io, &.{}); + try w.interface.writeAll(bytes); + try w.interface.flush(); +} + +pub fn main(init: std.process.Init) !void { + // `ARCH-031` rule 5 — INSTALL, then let the module ASSERT. Until M1.1.14's own + // review this entry point asserted through the harness and never installed, so + // every witness in the set was produced under the environment the OS handed it. + // The assertion passed because that inherited state happens to be the engine + // default on Linux and Windows — which is precisely why an assertion is a + // DETECTION mechanism and not a substitute for installation. + foundation.math.float_env.install(); + + const gpa = init.arena.allocator(); + const io = init.io; + const argv = try init.minimal.args.toSlice(gpa); + + var write_dir: ?[]const u8 = null; + var i: usize = 1; + while (i < argv.len) : (i += 1) { + if (std.mem.eql(u8, argv[i], "--write-witness") and i + 1 < argv.len) { + i += 1; + write_dir = argv[i]; + } + } + + std.debug.print("forge-determinism: scenario=canonical precision={s} mode={s} workers=1\n", .{ + precision_tag, mode_tag, + }); + + var a = try run.runCanonical(gpa, run.chain_frames); + defer a.deinit(gpa); + + // SELF-REPRODUCIBILITY, reported on every invocation and not only in the test + // suite: it is the claim every other line here rests on, and a run that + // cannot repeat itself makes the rest of the output meaningless rather than + // merely unverified. + var b = try run.runCanonical(gpa, run.chain_frames); + defer b.deinit(gpa); + const reproducible = std.mem.eql(u8, a.chain.items, b.chain.items) and + std.mem.eql(u8, a.discrete.items, b.discrete.items) and + std.mem.eql(u8, a.poses.items, b.poses.items); + std.debug.print("self-reproducible : {s} ({d} frames, {d} B chain, {d} B discrete, {d} B poses)\n", .{ + if (reproducible) "OK" else "FAIL", + run.chain_frames, + a.chain.items.len, + a.discrete.items.len, + a.poses.items.len, + }); + + // P2-3 — THE REPRODUCIBILITY GATE COMES BEFORE ANY SIDE EFFECT, and until this + // correction it came after. The files were written and `NotSelfReproducible` + // was returned afterwards, so a run that could not repeat itself left a + // COMPLETE AND USABLE witness set on disk — the exact inverse of the comment + // that accompanied it. A gate placed after the act it is meant to prevent is + // not a gate. + if (!reproducible) return error.NotSelfReproducible; + + // P1-2 — GENERATION WRITES AND EXITS. It reads no committed witness at all: + // not to compare, not to validate a length. + // + // The earlier form kept going, and the earlier correction — printing the + // comparison instead of gating on it — only fixed the case where the FORMAT is + // stable. The real case is a STRUCTURAL change: one more mobile body, a + // different `pose_stride`, and `divergenceFrame` raises + // `ReferenceWindowLengthMismatch`, which is an ERROR and not the `failed` + // boolean that was being neutralised. Under `set -euo pipefail` the CI step + // then dies AFTER writing the three files and BEFORE uploading them. + // + // MEASURED before this change, with one extra scalar per body in the pose dump: + // `rc=1`, `error: ReferenceWindowLengthMismatch`, three files on disk. And a + // structural change is exactly what the next scenario correction produces, so + // this path had to work before it was needed — the third time that ordering has + // imposed itself on this milestone, for the same reason each time. + if (write_dir) |dir| { + var name_buf: [128]u8 = undefined; + try writeFile(io, dir, try std.fmt.bufPrint(&name_buf, "continuous-chain-{s}-{s}.bin", .{ precision_tag, mode_tag }), a.chain.items); + try writeFile(io, dir, try std.fmt.bufPrint(&name_buf, "discrete-{s}.bin", .{precision_tag}), a.discrete.items); + try writeFile(io, dir, try std.fmt.bufPrint(&name_buf, "reference-window-{s}.bin", .{precision_tag}), a.poses.items); + std.debug.print("witnesses written : {s}\n", .{dir}); + std.debug.print("mode : GENERATION — no committed witness was read\n", .{}); + return; + } + + // THE CHAIN IS ALWAYS COMPARED, AND GATED ONLY WHERE LEVEL 1 APPLIES. The + // earlier form SKIPPED the comparison outright on any non-x86_64 host, which + // threw away the strongest signal available: measured at M1.1.14's close, the + // eight committed witnesses are BIT-IDENTICAL between `ubuntu-24.04` (x86_64) + // and aarch64-macOS, the 1000-frame chains included. + // + // That is not a surprise once the arithmetic is pinned. IEEE-754 specifies the + // correctly-rounded result of `+ - * /`, `sqrt` and comparisons, so two + // conforming implementations on the same inputs IN THE SAME ORDER give the same + // bits. The inter-ISA divergence sources are reassociation, contraction, + // transcendentals, denormals, rounding mode and extended precision — and this + // milestone removed every one of them: explicit left folds, no FMA, an in-tree + // cosine pinned to a bit table, FTZ/DAZ off, the rounding mode installed, and + // `-Dcpu=baseline`. + // + // **NO PROMISE IS ADDED, and C1.1 does not move.** Level 3 stays out of Phase 1: + // one measurement, one scenario, one machine pair of which one is not even in + // the matrix, and the property holds by the ABSENCE of a single transcendental + // on the path — the day a shape introduces one it would break, and a guard + // disabled at its first failure is not a guard. So on a non-level-1 host the + // comparison is REPORTED and never gated, exactly like the divergence frame: its + // REGRESSION is the signal, never its value. What the skip cost was the ability + // to learn the day the agreement ends. + var failed = false; + if (witness.chain) |w| { + const verdict: []const u8 = if (witness.chain_applies) "" else " (REPORTED, not gated — level 1 is intra-ISA)"; + if (witness.firstChainMismatch(a.chain.items, w, trace.digest_len)) |m| { + std.debug.print("chain verdict : MISMATCH at frame {d}{s}\n", .{ m.frame, verdict }); + if (witness.chain_applies) failed = true; + } else { + std.debug.print("chain verdict : OK ({d} frames){s}\n", .{ run.chain_frames, verdict }); + } + if (!witness.chain_applies) { + std.debug.print(" host is {t}; inter-ISA bit-identity is a MEASURED\n", .{builtin.cpu.arch}); + std.debug.print(" property as of M1.1.14, not a contract\n", .{}); + } + } else { + std.debug.print("chain verdict : SKIPPED (no witness for mode {s})\n", .{mode_tag}); + } + + // THE FOUR TRACE VERDICTS — level 2 point 1, every cell, ISA included. Named + // one by one because they are four independent claims and a single verdict + // would discard which invariant moved. + if (try witness.firstDiscreteMismatch(a.discrete.items, witness.discrete, run.window_frames)) |m| { + for (std.enums.values(witness.Trace)) |t| { + const bad = m.trace != null and m.trace.? == t; + std.debug.print("trace verdict : {s: <30} {s}\n", .{ + t.label(), + if (bad) "MISMATCH" else "not reached", + }); + } + std.debug.print(" first disagreement at frame {d}\n", .{m.frame}); + failed = true; + } else { + for (std.enums.values(witness.Trace)) |t| { + std.debug.print("trace verdict : {s: <30} OK\n", .{t.label()}); + } + } + + // The divergence frame, measured against the COMMITTED reference window. On + // the cell that produced it this is a self-comparison and must report none; + // on the ARM64 cell it is the level-2 point-2 characterisation, and its + // REGRESSION between milestones is the signal rather than its value. + const div = try run.divergenceFrame(gpa, witness.window, a.pose_stride); + if (div) |f| { + std.debug.print("divergence frame : {d}\n", .{f}); + } else { + std.debug.print("divergence frame : none within K={d}\n", .{run.window_frames}); + } + + // Verification only — the generation path returned above, so no `write_dir` + // term is needed here and none is written: a condition that can no longer be + // false is not a guard, and this file has already removed one such. + if (failed) return error.WitnessMismatch; +} diff --git a/src/modules/forge/forge_3d/mesh.zig b/src/modules/forge/forge_3d/mesh.zig index 447f2d95..bd69f0d6 100644 --- a/src/modules/forge/forge_3d/mesh.zig +++ b/src/modules/forge/forge_3d/mesh.zig @@ -927,7 +927,7 @@ pub fn aabbDistanceSq(box: Aabbr, point: Vec3r) Real { const below = @max(box.min.data - point.data, zeros); const above = @max(point.data - box.max.data, zeros); const d = below + above; // at most one term is non-zero per axis - return @reduce(.Add, d * d); + return math.reduce.foldAdd(d * d); } /// One (triangle, edge) endpoint of the adjacency sort. diff --git a/src/modules/forge/forge_3d/pipeline/broadphase.zig b/src/modules/forge/forge_3d/pipeline/broadphase.zig index 4e8400b0..acd6d866 100644 --- a/src/modules/forge/forge_3d/pipeline/broadphase.zig +++ b/src/modules/forge/forge_3d/pipeline/broadphase.zig @@ -1118,6 +1118,47 @@ pub fn Broadphase(comptime T: type) type { } } + /// The stored FAT AABB of a bounded proxy; `null` when `proxy` is unbounded. + /// + /// M1.1.14, and READ-ONLY BY DESIGN. Its consumer is the retention rule of + /// §1.7 step 2 — "removal on fat-AABB separation only" — which lives in + /// whoever owns the retained candidate set: the acceptance harness today, + /// `PhysicsWorld` at M1.1.15. The broadphase deliberately does NOT own that + /// rule: pair retention IS the wake graph (§1.8.7), so putting the policy + /// here would move an islands-and-sleep behaviour into the acceleration + /// structure. What is added is the DATUM the policy needs, which `Bvh` + /// already exposed and the multi-layer aggregate did not forward — the same + /// class as `BodyManager.entity()` at M1.1.10, a column that existed from the + /// first day and had simply never been reachable. + /// + /// The `null` is not a failure: an unbounded proxy has no box at all, and a + /// caller must reach for `unboundedShape` instead. Answering with a fabricated + /// infinite box is exactly what §1.11.15 refuses. + pub fn proxyAabb(self: *const Self, proxy: Proxy) ?AabbT { + const li = @intFromEnum(proxy.layer); + return switch (proxy.kind) { + .tree => self.trees[li].proxyAabb(proxy.id), + .unbounded => null, + }; + } + + /// The half-space an unbounded proxy carries; `null` when `proxy` is bounded. + /// + /// The other half of what the retention rule needs: a pair with a half-space on + /// one side has no second fat AABB to separate from, so its overlap test is + /// `Aabb.overlapsHalfSpace` — the same exact predicate the traversal uses, from + /// `foundation/math`, never a second copy (§1.11.15). + pub fn unboundedShape(self: *const Self, proxy: Proxy) ?UnboundedShape { + const li = @intFromEnum(proxy.layer); + return switch (proxy.kind) { + .tree => null, + .unbounded => blk: { + const slot = self.unbounded[li].items[proxy.id]; + break :blk if (slot.live) slot.shape else null; + }, + }; + } + /// Overlap query across every layer: `collector.add(user_data)` for each /// proxy whose fat AABB overlaps `query`. Returns the total nodes /// visited. `collector` is a pointer with `fn add(self, u32) void`. diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig index 5c29a06a..4325e4f6 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig @@ -484,7 +484,7 @@ fn terminal( /// never a multiplication by `1 / scale`, whose reciprocal overflows for a denormal. fn unitOf(comptime T: type, v: math.Vec(3, T)) ?math.Vec(3, T) { const Simd = @Vector(3, T); - const scale = @reduce(.Max, @abs(v.data)); + const scale = v.maxAbsComponent(); if (scale == 0) return null; const reduced: math.Vec(3, T) = .{ .data = v.data / @as(Simd, @splat(scale)) }; return reduced.scale(1 / reduced.length()); diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 1295c5db..72d78887 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -49,6 +49,9 @@ const query_mod = @import("query/root.zig"); // M1.1.12 — the kinematic character controller's store. Re-exported below; the // comptime pin analyses its acceptance suite. const character_mod = @import("character.zig"); +// M1.1.14 — the module's entry-point check on the floating-point execution +// state (`ARCH-031` rule 5). Scalar-free; re-exported as two functions below. +const determinism_mod = @import("determinism.zig"); // --- Solver scalar + math aliases --- @@ -300,10 +303,22 @@ pub fn integrate(bm: *BodyManager, dt: Real, gravity: Vec3r) void { /// siblings. Bound to `Real` through the package's `../config.zig` import. pub const rigid = rigid_mod; +// --- Determinism (M1.1.14) --- + +/// Read the float-environment state of the calling thread when it is NOT the +/// engine's, `null` otherwise — the module's entry-point check of `ARCH-031` +/// rule 5. `forge_3d` ASSERTS this state and never installs it; the reason the +/// two are different verbs is in `determinism.zig`. +pub const checkFloatEnvironment = determinism_mod.checkFloatEnvironment; +/// Assert that the calling thread carries the engine float environment. Called +/// by whatever drives a tick; `PhysicsWorld.step()` inherits the call at M1.1.15. +pub const assertFloatEnvironment = determinism_mod.assertFloatEnvironment; + // Pins so the inline tests + the acceptance suite are analysed when this module // is built as a test target (engine-zig-conventions.md §13). comptime { _ = config; + _ = determinism_mod; _ = shape; _ = mesh_mod; _ = body; @@ -335,4 +350,9 @@ comptime { _ = @import("tests/mesh_test.zig"); _ = @import("tests/character_test.zig"); _ = @import("tests/sensor_test.zig"); + // M1.1.14 — the determinism instrument: canonical scenario + artifacts. + _ = @import("tests/determinism/scenario.zig"); + _ = @import("tests/determinism/trace.zig"); + _ = @import("tests/determinism/run.zig"); + _ = @import("tests/determinism/witness.zig"); } diff --git a/src/modules/forge/forge_3d/tests/determinism/run.zig b/src/modules/forge/forge_3d/tests/determinism/run.zig new file mode 100644 index 00000000..2b2774fc --- /dev/null +++ b/src/modules/forge/forge_3d/tests/determinism/run.zig @@ -0,0 +1,348 @@ +//! The determinism harness AS AN INSTRUMENT — the entry two later milestones +//! replay: M1.1.25 at N workers, M1.A on a rebuilt scheduler DAG +//! (`engine-phase-1-plan.md`). It is therefore written as a library with a +//! stable entry, and `main.zig` is a thin shell over it rather than the other +//! way round: a harness whose only caller is a `main` is not replayable. +//! +//! ONE WORKER, and that is not a limitation of this file. Resolution is +//! sequential until M1.1.25 (`engine-physics-solver.md` §1.8.8), and the +//! invariance of the result to the worker count is STRUCTURAL — the resolution +//! order is total, `(island rank, pair_key, subshape_id)`, with no hashed +//! container anywhere — so the replay at N will VERIFY that invariance rather +//! than establish it. + +const std = @import("std"); +const config = @import("../../config.zig"); +const forge = @import("../../root.zig"); +const scenario = @import("scenario.zig"); +const trace = @import("trace.zig"); +const witness_mod = @import("witness.zig"); + +const Scenario = scenario.Scenario; +const Real = config.Real; + +/// Frames of the level-1 continuous chain (`engine-phase-1-criteria.md` C1.1). +pub const chain_frames: u32 = 1000; +/// Frames of the level-2 discrete parity window — `K = 60`, one second at 60 Hz, +/// fixed by C1.1 and not by this file. +pub const window_frames: u32 = 60; + +/// What one run produces: the three artifact kinds, and nothing derived. +pub const Artifacts = struct { + /// `chain_frames` digests, concatenated. The level-1 witness. + chain: std.ArrayListUnmanaged(u8) = .empty, + /// The four discrete traces over the first `window_frames`, raw. Level 2. + discrete: std.ArrayListUnmanaged(u8) = .empty, + /// Raw mobile poses over the first `window_frames`. Measurement only. + poses: std.ArrayListUnmanaged(u8) = .empty, + /// Bytes one frame contributes to `poses` — the stride a reader needs. + pose_stride: usize = 0, + + pub fn deinit(self: *Artifacts, gpa: std.mem.Allocator) void { + self.chain.deinit(gpa); + self.discrete.deinit(gpa); + self.poses.deinit(gpa); + } + + /// The digest of frame `i` (0-based). + pub fn link(self: *const Artifacts, i: usize) []const u8 { + return self.chain.items[i * trace.digest_len ..][0..trace.digest_len]; + } +}; + +/// Run the canonical scenario for `frames` ticks and produce all three artifacts. +/// +/// The float environment is ASSERTED here rather than installed, at the same +/// seam every physics entry uses (`../../determinism.zig`) — an instrument that +/// repaired its own thread would measure a state no other consumer has. +pub fn runCanonical(gpa: std.mem.Allocator, frames: u32) !Artifacts { + forge.assertFloatEnvironment(); + + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + var art = Artifacts{}; + errdefer art.deinit(gpa); + + var chain = trace.Chain{}; + var frame_buf: std.ArrayListUnmanaged(u8) = .empty; + defer frame_buf.deinit(gpa); + + var f: u32 = 0; + while (f < frames) : (f += 1) { + try s.step(gpa, f); + + frame_buf.clearRetainingCapacity(); + try trace.dumpState(&s, gpa, &frame_buf); + chain.advance(frame_buf.items); + try art.chain.appendSlice(gpa, &chain.digest); + + if (f < window_frames) { + try trace.dumpDiscrete(&s, gpa, &art.discrete); + const before = art.poses.items.len; + try trace.dumpPoses(&s, gpa, &art.poses); + if (f == 0) art.pose_stride = art.poses.items.len - before; + } + } + return art; +} + +/// The first frame at which the run's poses deviate from `reference` by more than +/// `1e-4 × body scale`, or `null` if none does within the window. +/// +/// A MEASUREMENT, not a gate (C1.1 level 2 point 2): its value is recorded and +/// its REGRESSION is the signal — a sharp drop between two milestones denounces a +/// threshold that has become fragile. Nothing here asserts a bound on it. +/// +/// **THE REFERENCE LENGTH IS REQUIRED EXACTLY, and that is P2-6's correction.** The +/// loop used to carry `if (off + stride > reference.len) return null;` — so a +/// reference that was EMPTY, TRUNCATED, or of the wrong precision returned `null`, +/// which the entry point prints as `divergence frame : none within K=60`. Absent +/// data read as a perfect match: the milestone's own defect family, in the function +/// that produces its level-2-point-2 number. The two outcomes are now separated — +/// `null` means measured and no divergence, an error means the input was not a +/// window and no measurement was taken. +/// +/// `stride` is the caller's, from `Artifacts.pose_stride`, so the check also catches +/// a reference of the OTHER precision: the f64 window is exactly twice the f32 one +/// and neither length divides the other's. +pub fn divergenceFrame(gpa: std.mem.Allocator, reference: []const u8, stride: usize) !?u32 { + forge.assertFloatEnvironment(); + + if (stride == 0) return error.ReferenceWindowEmptyStride; + if (reference.len != stride * window_frames) return error.ReferenceWindowLengthMismatch; + + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + var f: u32 = 0; + while (f < window_frames) : (f += 1) { + try s.step(gpa, f); + // No bounds test here, and it is not an omission: the entry check makes + // `off + stride <= reference.len` true for every `f < window_frames` by + // arithmetic. Keeping a runtime guard that cannot fire is what let the + // silent `null` live in the first place — a guard whose only reachable + // branch is the one nobody wanted. + const off = @as(usize, f) * stride; + if (trace.deviationExceeded(&s, reference[off..][0..stride])) return f; + } + return null; +} + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; + +test "harness is self-reproducible" { + // THE Gate B exit criterion. Two consecutive in-process runs must produce + // byte-identical outputs of ALL THREE kinds — not merely of the chain, since + // the chain is a digest and would hide which of the three moved. + // + // Self-reproducibility is the weakest of the milestone's claims and the one + // everything else rests on: a harness that cannot repeat itself on ONE + // machine cannot say anything about two. + const gpa = testing.allocator; + const frames: u32 = 120; + + var a = try runCanonical(gpa, frames); + defer a.deinit(gpa); + var b = try runCanonical(gpa, frames); + defer b.deinit(gpa); + + // SIZE FIRST, and reported as a size rather than asserted as equality alone: + // two empty artifacts are byte-identical too, and that is the vacuity this + // milestone has already met twice. + try testing.expectEqual(@as(usize, frames * trace.digest_len), a.chain.items.len); + try testing.expect(a.discrete.items.len > 0); + try testing.expect(a.poses.items.len > 0); + try testing.expectEqual(a.pose_stride * window_frames, a.poses.items.len); + + try testing.expectEqualSlices(u8, a.chain.items, b.chain.items); + try testing.expectEqualSlices(u8, a.discrete.items, b.discrete.items); + try testing.expectEqualSlices(u8, a.poses.items, b.poses.items); +} + +test "the chain actually advances — no two frames share a link" { + // The chain is the level-1 witness, and a chain that stopped advancing would + // compare equal to itself for ever. Adjacent links are asserted DISTINCT, + // which is what a scene where something moves must produce. + const gpa = testing.allocator; + var a = try runCanonical(gpa, 30); + defer a.deinit(gpa); + + var i: usize = 1; + var distinct: usize = 0; + while (i < 30) : (i += 1) { + if (!std.mem.eql(u8, a.link(i - 1), a.link(i))) distinct += 1; + } + try testing.expectEqual(@as(usize, 29), distinct); +} + +test "the discrete trace is not constant across the window" { + // Same guard, on the level-2 artifact and for the same reason. The four + // invariants are supposed to CHANGE over the window — the stack falls asleep, + // the two groups merge, the visitor crosses the trigger — so a window whose + // every frame encoded the same bytes would be a scene where nothing happened, + // and its parity across two ISAs would prove nothing. + const gpa = testing.allocator; + var a = try runCanonical(gpa, window_frames); + defer a.deinit(gpa); + + // The frames are variable-length, so constancy is tested on the whole window + // against its own first half rather than frame by frame. + const half = a.discrete.items.len / 2; + try testing.expect(half > 0); + try testing.expect(!std.mem.eql(u8, a.discrete.items[0..half], a.discrete.items[half..][0..half])); +} + +test "the deviation metric fires on a run against a shifted reference" { + // DISCRIMINATION for `divergenceFrame`. Measured against its own poses, the + // metric must never fire — that is the self-consistency half. Against a + // reference deliberately displaced by a metre it must fire at the FIRST + // frame, which is what proves the comparison is wired to the reference at all + // and not answering from the run alone. + const gpa = testing.allocator; + var a = try runCanonical(gpa, window_frames); + defer a.deinit(gpa); + + try testing.expectEqual(@as(?u32, null), try divergenceFrame(gpa, a.poses.items, a.pose_stride)); + + const shifted = try gpa.dupe(u8, a.poses.items); + defer gpa.free(shifted); + // Displace the X of the first body of every frame by one metre. + const w = @sizeOf(if (Real == f32) u32 else u64); + var f: usize = 0; + while (f < window_frames) : (f += 1) { + const off = f * a.pose_stride; + const bits = std.mem.readInt(if (Real == f32) u32 else u64, shifted[off..][0..w], .little); + const v: Real = @bitCast(bits); + const moved: Real = v + 1; + std.mem.writeInt(if (Real == f32) u32 else u64, shifted[off..][0..w], @bitCast(moved), .little); + } + try testing.expectEqual(@as(?u32, 0), try divergenceFrame(gpa, shifted, a.pose_stride)); +} + +test "divergenceFrame refuses a window it cannot measure, instead of reporting none" { + // P2-6. The three ways a caller can hand over something that is not a window, + // each of which used to return `null` — and `null` is printed as "no divergence + // within K=60", so absent data read as a perfect match. + // + // A POSITIVE WITNESS FIRST, because "it errors on bad input" is satisfied by a + // function that errors on everything. + const gpa = testing.allocator; + var a = try runCanonical(gpa, window_frames); + defer a.deinit(gpa); + try testing.expect(a.pose_stride > 0); + try testing.expectEqual(a.pose_stride * window_frames, a.poses.items.len); + // A self-comparison: the same run against its own poses must measure, and + // measure no divergence. This is the case the guard must NOT break. + try testing.expectEqual(@as(?u32, null), try divergenceFrame(gpa, a.poses.items, a.pose_stride)); + + // (1) EMPTY. The commonest shape of the defect: a witness file that failed to + // load, or one that was never written. + try testing.expectError( + error.ReferenceWindowLengthMismatch, + divergenceFrame(gpa, &.{}, a.pose_stride), + ); + + // (2) TRUNCATED. One frame short — the shape a run that stopped early leaves. + try testing.expectError( + error.ReferenceWindowLengthMismatch, + divergenceFrame(gpa, a.poses.items[0 .. a.poses.items.len - a.pose_stride], a.pose_stride), + ); + + // (3) THE WRONG PRECISION. An f64 window is exactly twice an f32 one, so + // reading one as the other is a length error and not a subtle mis-parse. Both + // directions are constructed from the real stride rather than a literal. + try testing.expectError( + error.ReferenceWindowLengthMismatch, + divergenceFrame(gpa, a.poses.items, a.pose_stride * 2), + ); + try testing.expectError( + error.ReferenceWindowLengthMismatch, + divergenceFrame(gpa, a.poses.items, a.pose_stride / 2), + ); + + // (4) A ZERO STRIDE, which is what `Artifacts.pose_stride` holds before the + // first frame has been dumped. Named apart because `0 * window_frames == 0` + // would let an EMPTY reference through the length test as a match. + try testing.expectError( + error.ReferenceWindowEmptyStride, + divergenceFrame(gpa, &.{}, 0), + ); +} + +test "every discrete trace VARIES inside the window the witnesses cover" { + // P1-1, AND THE DEFECT IT REPLACES WAS IN THE SPECIFICATION OF THE PROBE, not + // in the engine. The non-vacuity probe that preceded this one searched over 400 + // frames and found a retained-pair removal at frame 196 — but the committed + // `discrete-*.bin` witnesses hold `window_frames` = 60. So inside the window + // that is actually COMPARED, three of the four traces were CONSTANT: island + // partition, sleep state and the retained pair set each took exactly ONE value + // over the sixty frames. They agreed between x86_64 and AArch64 because they did + // not move. A probe that measures one window and renders a verdict on another is + // the family this milestone is about, and it had reached the central oracle. + // + // THE MEASUREMENT IS ON BYTES, not on cardinalities, and that distinction was + // also measured: a first probe counted `constraints.items.len` and reported the + // manifold trace as constant at 11, while the SERIALISED segment takes 7 distinct + // values over the same frames. A count is not the trace; the trace is what the + // witness holds. + // + // WHAT MADE THE THREE MOVE IS SCENE TUNING, since a trace's variation is a + // property of the scenario and not of the physics. Measured before: island + // partition first changed at frame 71 and the first sleeper appeared at 70 — + // the same event one tick apart, a sleeper leaving the partition — and the first + // retained-pair removal was at 196. All three outside the window, two of them + // barely. After tuning: 30, 29 and 49. + const gpa = testing.allocator; + var art = try runCanonical(gpa, window_frames); + defer art.deinit(gpa); + + const names = [_][]const u8{ + "island partition", + "sleep state", + "per-pair manifold cardinality", + "retained pair set", + }; + var distinct = [_]usize{ 0, 0, 0, 0 }; + var seen: [4]std.ArrayListUnmanaged([]const u8) = .{ .empty, .empty, .empty, .empty }; + defer for (&seen) |*l| l.deinit(gpa); + + var off: usize = 0; + var f: u32 = 0; + while (f < window_frames) : (f += 1) { + const sp = try witness_mod.frameSpans(art.discrete.items, off); + var start = off; + for (sp.ends, 0..) |end, k| { + const seg = art.discrete.items[start..end]; + var known = false; + for (seen[k].items) |prev| { + if (std.mem.eql(u8, prev, seg)) { + known = true; + break; + } + } + if (!known) { + try seen[k].append(gpa, seg); + distinct[k] += 1; + } + start = end; + } + off = sp.ends[3]; + } + + // EVERY trace, not the one that happened to move. A constant trace discriminates + // NOTHING between two architectures whichever one it is, so the requirement is + // uniform and is asserted per trace rather than as an aggregate — an aggregate + // would be satisfied by one trace moving twelve times while three stand still, + // which is exactly the state this test was written to end. + for (names, distinct) |n, d| { + errdefer std.debug.print("trace `{s}` takes {d} distinct value(s) over {d} frames\n", .{ n, d, window_frames }); + try testing.expect(d >= 2); + } + + // The window must also be the one the witnesses cover, or the check above drifts + // back to measuring something else. Read from the artifact rather than restated. + try testing.expectEqual(@as(usize, 0), art.discrete.items.len - off); +} diff --git a/src/modules/forge/forge_3d/tests/determinism/scenario.zig b/src/modules/forge/forge_3d/tests/determinism/scenario.zig new file mode 100644 index 00000000..07e94ef4 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/determinism/scenario.zig @@ -0,0 +1,1275 @@ +//! The CANONICAL determinism scenario of M1.1.14 — frozen by the brief. +//! +//! Fixed step 60 Hz, `substep_count` at its M1.1.13.1 default, ONE worker, and +//! **no RNG anywhere**: every initial value below is a literal. What the scenario +//! is for is not to be a plausible game scene — it is to make every mechanism +//! whose determinism the milestone claims actually RUN, so that a witness taken +//! over it can fail when one of them moves. +//! +//! **Body creation order is part of the contract.** `BodyId` is a slot index, so +//! creation order fixes the island rank (`engine-physics-solver.md` §1.8.1, rank +//! = smallest member `BodyId`) and therefore the order constraints are resolved +//! in. Reordering the `init` below is not a refactor; it is a different scenario, +//! and every committed witness becomes wrong. The order is numbered in the code. +//! +//! NINE elements, each present for a named reason and none decorative — and a +//! REASON FOR PRESENCE IS NOT AN ORACLE, which is the correction of M1.1.14's own +//! closing review: three of these nine were listed with their reason while their +//! effect fell outside the compared window, outside every artifact, or both. Each +//! entry below now states WHERE its effect is observed. +//! +//! 1. **A static half-space ground.** The surface everything rests on, and the +//! one shape with no AABB at all — it exercises the unbounded proxy list +//! (`engine-physics-shapes.md` §1.11.15) rather than the BVH. +//! 2. **The five-box stack of M1.1.13.1.** Manifold cardinality 4, sleep +//! transitions, and the deepest chain of contacts in the scene. +//! 3. **Two groups starting apart and colliding partway through.** The island +//! partition changes in BOTH DIRECTIONS ON THESE TWO GROUPS — separate, then one +//! island, then separate again — established by following island MEMBERSHIP and +//! not by any count. MEASURED after the review: they never met inside the window +//! at all, the gap closing to 2.272 m and reopening, and `max_islands > +//! min_islands` passed on the sleeper alone, which is why that predicate is now +//! an unattributed coverage probe rather than this element's witness. Now +//! frictionless with restitution 0.5, and they merge at frame 15 — inside the +//! 60-frame window, measured. A dedicated test requires SEPARATE → TOGETHER → +//! SEPARATE on the island MEMBERSHIP of these two groups; it requires nothing of +//! the global island count, which is the separate and coarser probe above. +//! 4. **The frictionless slider of M1.1.13.1.** Carries that milestone's named +//! ULP residual into the instrument, which is what Gate E re-measures. +//! 5. **A sphere crossing the internal edge of a static `MeshShape`.** Several +//! constraints per body pair, hence the THIRD term of the ordering key +//! (`subshape_id`, M1.1.11.1) — without a mesh the key's totality is never +//! exercised and a two-term key would pass every trace. +//! 6. **One sensor and one body entering then leaving it.** Sensor state and both +//! deltas (`engine-physics-solver.md` §1.13.11) — SERIALISED INTO THE CHAIN since +//! the review, because a trigger resolves no impulse: all three sets can diverge +//! without displacing a body, so every artifact stayed identical while the sensor +//! pass disagreed. The visitor now crosses INSIDE the 60-frame window. +//! 7. **A lone box that settles at once and sleeps early.** Sleep state and the +//! island partition were both CONSTANT over the compared window before it. +//! 8. **A riser and three ramps, one walkable and two not, forming a closed bowl.** +//! They exist so that `cos_max_slope` DECIDES something: their surface cosines +//! bracket `cos(0.785) = 0.70738` at `0.894` and `0.6247`, and the bracket is +//! proven to bite in BOTH directions by a counter-factual at the bottom of this +//! file — metres of trajectory, not centimetres. Mesh ramps with literal +//! vertices, so the scenario contains no trigonometry of its own. +//! 9. **A kinematic character on a scripted path across that terrain.** The +//! controller — its slope test, its step-up arm and its slide — and the site +//! where a wrong `max_slope` conversion surfaces first, which is the whole +//! reason the deterministic cosine exists. +//! +//! **Elements 3, 6, 8 and 9 were each defective until M1.1.14's review, and they are +//! four instances of ONE class: a reason for presence read as an oracle.** This header claimed "a step and a slope" while the +//! scene held neither — nothing stood near the character but the flat half-space, +//! so `max_slope` was never approached; and the character entered NO artifact, +//! because `mobile` holds rigid bodies and a virtual character owns none, so the +//! controller ran for a thousand frames and every bit of its output was discarded. +//! A text asserting more than its code, and a computation with no observer: the +//! milestone's two dominant families, in the file that defines what it measures. +//! +//! The elements are laid out in separate regions of X so that only the +//! interactions listed above occur. The half-space is the exception: it is +//! infinite and underlies all of them, which is intended. **That separation was +//! itself false at x = 100** and is now true: see the note at element 8 for the +//! arithmetic that put two other elements through the character's old region. + +const std = @import("std"); +const config = @import("../../config.zig"); +const api = @import("weld_forge"); +const harness = @import("../solver_test.zig"); +const character_mod = @import("../../character.zig"); +const shape_mod = @import("../../shape.zig"); +const foundation = @import("foundation"); + +const Real = config.Real; +const Vec3r = config.Vec3r; +const BodyId = api.BodyId; +const Vec3 = foundation.math.Vec3; + +const vr = harness.vr; +const av3 = harness.av3; + +/// Fixed timestep — 60 Hz, the tick the whole contract is expressed in. +pub const fixed_dt: Real = 1.0 / 60.0; +/// Gravity, a literal like everything else here. +pub const gravity: Vec3r = .{ .data = .{ 0, -9.81, 0 } }; + +/// The scenario, its bodies, and the one character. +/// +/// Owns a `harness.World` — the published per-tick cycle, the same one every +/// acceptance test drives, so the instrument measures the engine and not a +/// second copy of the cycle written for it. +pub const Scenario = struct { + world: harness.World, + chars: character_mod.CharacterStore = .{}, + character: api.CharacterId = undefined, + + /// The five-box stack, bottom to top. + stack: [5]BodyId = undefined, + /// The two groups that start apart and collide. + group_a: [2]BodyId = undefined, + group_b: [2]BodyId = undefined, + /// The frictionless slider of M1.1.13.1. + slider: BodyId = undefined, + /// The sphere that crosses the mesh's internal edge. + mesh_sphere: BodyId = undefined, + /// The trigger, and the body that enters and leaves it. + trigger: BodyId = undefined, + trigger_visitor: BodyId = undefined, + /// A lone box whose only job is to fall asleep inside the COMPARED window. + lone_sleeper: BodyId = undefined, + /// The character's terrain: a riser it climbs, a ramp it walks up, and two it + /// cannot — the far one and the near one, which together close the excursion + /// into a bowl. All static, so none enters `mobile`. + /// + /// The ramps exist to make `cos_max_slope` LOAD-BEARING, which is the whole + /// reason the deterministic cosine was written: their surface cosines are + /// `0.894` and `0.6247`, bracketing `cos(0.785) = 0.70738` by `0.187` and + /// `0.083`. Before M1.1.14's review this scenario had no relief at all — its + /// header claimed "a step and a slope" while nothing stood near the character + /// but the flat half-space, so `max_slope` was never approached and the cosine + /// the milestone added was exercised by no witness. + /// + /// **The slope test is the character's ONLY reason for stopping where it does, + /// and that is measured rather than assumed** (see the counter-factual test). + /// The steep ramps are not surfaces it stands on — it butts into them and the + /// verdict holds it there — so what carries the cosine into the witness is the + /// POSITION, not the `GroundState`, which stays `.grounded` for all 1000 frames. + step_block: BodyId = undefined, + walk_slope: BodyId = undefined, + steep_slope: BodyId = undefined, + back_slope: BodyId = undefined, + /// The LAST `moveCharacter` result, kept so a test can observe which body the + /// controller actually stood on. + /// + /// **A POSITION BAND IS A PROXY FOR A CONTACT AND THIS IS THE CONTACT.** The + /// scope test used to infer "the character reached the riser" from a box drawn + /// around the tread; `ground_body` is the controller's own answer to the same + /// question, so the assertion stops depending on a rectangle staying correct. + /// The result was DISCARDED here until M1.1.14's review — `_ = moveCharacter(...)` + /// — which is why no test could name what the character was standing on. + last_move: character_mod.MoveResult = .{ + .position = Vec3r.zero, + .ground = .{}, + }, + /// The two static bodies, kept because a `BodyId` is a GENERATIONAL handle + /// (`index:24 | generation:8`) and not a slot number: a probe that assumed + /// "the ground is body 0" would silently observe nothing. Measured — that is + /// exactly how the scope test below failed on its first run. + ground: BodyId = undefined, + mesh_body: BodyId = undefined, + + /// Every MOBILE body, in creation order — the set the continuous deviation + /// metric covers. + /// + /// The half-space ground and the static mesh are deliberately EXCLUDED. A + /// static shape with an unbounded local AABB has no meaningful scale, and the + /// metric weights by body radius; forcing one into it would mean inventing a + /// radius, which is the fabricated-constant class this module refuses + /// elsewhere. + mobile: std.ArrayListUnmanaged(BodyId) = .empty, + + /// Build the scenario. **The order of the numbered blocks IS the contract.** + pub fn init(gpa: std.mem.Allocator) !Scenario { + // Sleeping is ON: the scenario is an instrument for determinism, not a + // convergence measurement, and the sleep transitions are one of the four + // discrete traces. `initNoSleep` is the world every CONVERGENCE + // measurement uses (§1.8.3) and would silence that trace entirely. + var self = Scenario{ .world = harness.World.init(gravity, fixed_dt) }; + errdefer self.deinit(gpa); + const w = &self.world; + + // --- (1) the static half-space ground --------------------------------- + const plane = try w.store.createShape(gpa, .{ .plane = .{} }); + var ground = api.BodyDescriptor{ + .entity = .{ .index = 0, .generation = 0 }, + .body_type = .static, + .shape = plane, + }; + ground.friction = 0.5; + ground.restitution = 0; + self.ground = try w.addBody(gpa, ground); + + // --- (2) the five-box stack, x = 0 ----------------------------------- + const unit_box = try w.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + for (&self.stack, 0..) |*id, i| { + var d = api.BodyDescriptor{ + .entity = .{ .index = @intCast(1 + i), .generation = 0 }, + .body_type = .dynamic, + .shape = unit_box, + }; + // Analytic rest heights: box i sits flush on the one below, so the + // stack starts with zero penetration everywhere and the transient is + // the solver's own rather than the scene's. + d.position = av3(0, @floatCast(0.5 + @as(Real, @floatFromInt(i))), 0); + d.mass = 1; + d.restitution = 0; + id.* = try w.addBody(gpa, d); + try self.mobile.append(gpa, id.*); + } + + // --- (3) two groups, apart at t = 0, colliding inside the window ------ + // + // TUNED, AND THE NUMBERS HERE ARE THE CURRENT ONES. Centres at 35 and 38, so + // a 2 m gap between surfaces; ±4 m/s, hence 8 m/s of closing speed; + // frictionless and undamped so the ground does not bleed it away. MEASURED: + // the two groups are in separate islands from frame 0, share one island at + // frame 15, and are separate again at frame 21 — all three inside the + // 60-frame window the witnesses cover. + // + // The comment this replaces described the ORIGINAL scene — 4 m/s of closing + // speed over a 12 m gap, contact around frame 180 — and survived the retune + // that moved every one of those figures. A stale tuning comment is a false + // live contract, not a detail: it is what a reader consults to know whether + // the element still does what it is for. + for (&self.group_a, 0..) |*id, i| { + var d = api.BodyDescriptor{ + .entity = .{ .index = @intCast(10 + i), .generation = 0 }, + .body_type = .dynamic, + .shape = unit_box, + }; + d.position = av3(35, @floatCast(0.5 + @as(Real, @floatFromInt(i))), 0); + d.mass = 1; + // FRICTION AND DAMPING OFF, RESTITUTION 0.5 — three changes with one + // purpose. The pair had to MEET inside the compared window and it did + // not: measured, the closest the two groups came in 60 frames was + // 2.272 m between centres, because ground friction bled the closing + // speed away. And it had to meet AND PART, or the island trace shows + // only a merge and a merge-only scene passes a weaker test than the + // one intended. + d.restitution = 0.5; + d.friction = 0; + d.linear_damping = 0; + d.angular_damping = 0; + id.* = try w.addBody(gpa, d); + try self.mobile.append(gpa, id.*); + } + for (&self.group_b, 0..) |*id, i| { + var d = api.BodyDescriptor{ + .entity = .{ .index = @intCast(20 + i), .generation = 0 }, + .body_type = .dynamic, + .shape = unit_box, + }; + d.position = av3(38, @floatCast(0.5 + @as(Real, @floatFromInt(i))), 0); + d.mass = 1; + d.restitution = 0.5; + d.friction = 0; + d.linear_damping = 0; + d.angular_damping = 0; + id.* = try w.addBody(gpa, d); + try self.mobile.append(gpa, id.*); + } + for (self.group_a) |id| w.bm.setLinearVelocity(id, vr(4, 0, 0)); + for (self.group_b) |id| w.bm.setLinearVelocity(id, vr(-4, 0, 0)); + + // --- (4) the frictionless slider, z = 20 ------------------------------ + var slider = api.BodyDescriptor{ + .entity = .{ .index = 30, .generation = 0 }, + .body_type = .dynamic, + .shape = unit_box, + }; + slider.position = av3(-30, 0.5, 20); + slider.mass = 1; + slider.friction = 0; + slider.restitution = 0; + // Damping OFF, both channels. At the default 0.05 a 5 m/s slider loses + // 4.756 m/s over sixty ticks — measured at M1.1.11.1 — which would swamp + // the ULP-scale residual this element exists to carry. + slider.linear_damping = 0; + slider.angular_damping = 0; + self.slider = try w.addBody(gpa, slider); + try self.mobile.append(gpa, self.slider); + w.bm.setLinearVelocity(self.slider, vr(5, 0, 0)); + + // --- (5) static mesh + the sphere that crosses its internal edge ------ + // Two coplanar triangles sharing the diagonal (v1, v2). That shared edge + // is the INTERNAL one: paired, therefore inactive, therefore corrected — + // and a sphere crossing it produces several constraints for ONE body + // pair, which is what exercises the third term of the ordering key. + const mesh_verts = [_]Vec3{ + av3(55, 2, -3), + av3(65, 2, -3), + av3(55, 2, 3), + av3(65, 2, 3), + }; + const mesh_idx = [_]u32{ 0, 2, 1, 1, 2, 3 }; + const mesh = try w.store.createShape(gpa, .{ .triangle_mesh = .{ + .vertices = &mesh_verts, + .indices = &mesh_idx, + } }); + var mesh_body = api.BodyDescriptor{ + .entity = .{ .index = 40, .generation = 0 }, + .body_type = .static, + .shape = mesh, + }; + mesh_body.friction = 0.5; + mesh_body.restitution = 0; + self.mesh_body = try w.addBody(gpa, mesh_body); + + const sphere = try w.store.createShape(gpa, .{ .sphere = .{ .radius = 0.5 } }); + var ms = api.BodyDescriptor{ + .entity = .{ .index = 41, .generation = 0 }, + .body_type = .dynamic, + .shape = sphere, + }; + ms.position = av3(56, 2.5, 0); + ms.mass = 1; + ms.friction = 0; + ms.restitution = 0; + ms.linear_damping = 0; + ms.angular_damping = 0; + self.mesh_sphere = try w.addBody(gpa, ms); + try self.mobile.append(gpa, self.mesh_sphere); + w.bm.setLinearVelocity(self.mesh_sphere, vr(12, 0, 0)); + + // --- (6) the trigger and its visitor, x = 80 ------------------------- + const trigger_box = try w.store.createShape(gpa, .{ .box = .{ .half_extents = av3(2, 2, 2) } }); + var trig = api.BodyDescriptor{ + .entity = .{ .index = 50, .generation = 0 }, + .body_type = .static, + .shape = trigger_box, + }; + // y = 3, not 2. MEASURED: at y = 2 the box spans y ∈ [0, 4] and its lowest + // face touches the half-space boundary exactly, so the trigger detects the + // GROUND as well — a second, permanent pair, and the scope test below + // reported two `entered` instead of one. Lifting it clear leaves the + // element as the brief describes it: one sensor, one body crossing. + trig.position = av3(80, 3, 0); + trig.is_trigger = true; + // The visitor is on the default object layer, so the trigger's mask must + // admit it. A mask of zero detects nothing and the two deltas would never + // fire — the shape of a trace that passes by producing nothing. + trig.trigger_layer_mask = 0xFFFF_FFFF; + self.trigger = try w.addBody(gpa, trig); + + var visitor = api.BodyDescriptor{ + .entity = .{ .index = 51, .generation = 0 }, + .body_type = .dynamic, + .shape = unit_box, + }; + visitor.position = av3(76, 2, 0); + visitor.mass = 1; + visitor.restitution = 0; + visitor.friction = 0; + visitor.linear_damping = 0; + visitor.angular_damping = 0; + // No gravity on the visitor: it must fly THROUGH the trigger and out the + // far side, producing one `entered` and one `exited`. Under gravity it + // would land on the ground plane and the exit would come from falling, + // which is a different observable. + visitor.gravity_factor = 0; + self.trigger_visitor = try w.addBody(gpa, visitor); + try self.mobile.append(gpa, self.trigger_visitor); + w.bm.setLinearVelocity(self.trigger_visitor, vr(12, 0, 0)); + self.world.sensors_on = true; + + // --- (7) a lone box that settles at once, x = 15 ----------------------- + // + // ITS ONLY JOB IS TO SLEEP INSIDE THE COMPARED WINDOW. Sleep state and the + // island partition were both CONSTANT over the 60 frames the witnesses + // actually cover — the five-box stack first sleeps at frame 70 and the + // island count first moves at 71, which is the same event one tick later, + // a sleeper leaving the partition. Both fell just outside. + // + // A lone box on the half-space has no stack transient to spend, so it goes + // still almost immediately and sleeps `time_before_sleep` later. Isolated at + // x = 15, where nothing passes: the stack is at 0, the groups at 35-38, the + // slider runs in the z = 20 lane, the mesh begins at 55. + var lone = api.BodyDescriptor{ + .entity = .{ .index = 65, .generation = 0 }, + .body_type = .dynamic, + .shape = unit_box, + }; + lone.position = av3(15, 0.5, 0); + lone.mass = 1; + lone.restitution = 0; + self.lone_sleeper = try w.addBody(gpa, lone); + try self.mobile.append(gpa, self.lone_sleeper); + + // --- (8) the character's terrain, x = −70 … −59 ---------------------- + // + // WHY NEGATIVE X, and it is a correction rather than a preference. The + // character sat at x = 100 and the header promised that elements occupy + // separate X regions so only the listed interactions occur. That promise + // was already false there, by arithmetic on this file's own constants: the + // frictionless mesh sphere leaves the mesh at x = 65 carrying 3 m/s and + // reaches x ≈ 106 by frame 1000, and the trigger visitor starts at x = 72 + // with 4 m/s and no gravity, reaching x ≈ 138. Both cross x = 100. Putting + // terrain there would couple three elements that the design keeps apart, + // so the character moves to the one region nothing else visits — the + // slider starts at x = −30 and travels toward +x, in the z = 20 lane. + // + // THE SLOPES ARE MESHES AND NOT ROTATED BOXES, and that is a MEASURED + // decision. Rotated boxes were tried first and abandoned after three + // rounds: a box rotated about +Z has a footprint wider than its + // half-extent by `|sin| · h`, so its leading edge is a corner at a height + // the reader must derive rather than read, its Z faces stay VERTICAL — and + // a vertical face is never ground whatever `max_slope` says + // (`character.zig`), so a character arriving along Z is blocked without the + // slope test ever running — and one placement floated the wedge 0.25 m + // above the plane and wedged the character in the crevice underneath for + // 775 of 1000 frames. A mesh ramp has literal vertices: the surface is + // exactly where the numbers say, the walkable ramp and the steep one meet + // at a vertex they SHARE to the bit, and there is no hidden extent to get + // wrong. + // + // NO TRIGONOMETRY, which matters in this file above all others: an `@sin` + // in the scenario would put back into the instrument exactly what + // `ARCH-031` rule 4 took out of the engine. A ramp's normal is the cross + // product of two exact integer-ish edges, so its cosine is exact algebra: + // rise 1 over run 2 → n · up = 2/√5 = 0.894 → WALKABLE (≥ 0.70738) + // rise 2.5 over run 2 → n · up = 0.6247 → TOO STEEP (< 0.70738) + // The margins are 0.187 and 0.083 — the tighter one is 700 000 f32 + // epsilons, so no float noise can flip a verdict, while a cosine wrong in + // its first decimal flips one and a cosine wrong in both directions flips + // both. Both cosines checked against an independent computation, not read + // off the vertex table. + // THAT BRACKET IS THE POINT: an erroneously LARGE `cos_max_slope` makes the + // walkable ramp unclimbable and an erroneously SMALL one makes the steep + // ramp climbable, and each shows up as metres of difference in a position + // the continuous state carries. + + // The riser, and its height is MEASURED rather than chosen. `tryStepUp` + // lifts by `step_height` and then advances by the motion REMAINING in that + // tick, so whether a riser is climbed depends on the caller's per-tick step + // and not on the riser height alone. Swept here: at 0.03 m/tick — the walk + // this scenario used before M1.1.14's review — NO riser is ever climbed, + // 0.15 m and 0.25 m alike; 0.15 m needs 0.06 m/tick and 0.25 m needs 0.10. + // So the scenario walked below the threshold of its own step arm, and the + // walk is now 0.06 with a 0.15 m riser: the cheapest pair that exercises + // `tryStepUp` at all. Not a defect in the controller — a sweep-based step + // arm cannot lift a character that never commits enough forward motion to + // land on the tread — but it is invisible without the sweep. + const riser_box = try w.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 1, 4) } }); + var riser = api.BodyDescriptor{ + .entity = .{ .index = 61, .generation = 0 }, + .body_type = .static, + .shape = riser_box, + }; + // Top face at y = 0.15, the body sunk well below the half-space rather than + // resting flush on it: static × static is `false` in the layer matrix so + // there is no pair either way, but a face exactly coplanar with the + // boundary is the configuration M1.1.13 measured a spurious second contact + // from, and it costs nothing to not reproduce it. + riser.position = av3(-65.5, -0.85, 0); + riser.friction = 0.5; + riser.restitution = 0; + self.step_block = try w.addBody(gpa, riser); + + // The walkable ramp: from the plane at x = −62 up to y = 1 at x = −60. + // WINDING IS LOAD-BEARING — a `MeshShape` is single-sided (§1.11.17), so a + // reversed triangle is a surface the character falls through. It is not + // asserted from the vertex order but OBSERVED: the coverage test below + // requires the character to reach a height only this ramp can give it. + const walk_verts = [_]Vec3{ + av3(-62, 0, -4), av3(-60, 1, -4), + av3(-62, 0, 4), av3(-60, 1, 4), + }; + const ramp_idx = [_]u32{ 0, 2, 1, 1, 2, 3 }; + const walk_mesh = try w.store.createShape(gpa, .{ .triangle_mesh = .{ + .vertices = &walk_verts, + .indices = &ramp_idx, + } }); + var walkable = api.BodyDescriptor{ + .entity = .{ .index = 62, .generation = 0 }, + .body_type = .static, + .shape = walk_mesh, + }; + walkable.friction = 0.5; + walkable.restitution = 0; + self.walk_slope = try w.addBody(gpa, walkable); + + // The steep ramp, continuing from the walkable one's crest at exactly + // (−60, 1) — shared to the bit, which is what leaves no crevice between + // them for the character to fall into. + const steep_verts = [_]Vec3{ + av3(-60, 1, -4), av3(-58, 3.5, -4), + av3(-60, 1, 4), av3(-58, 3.5, 4), + }; + const steep_mesh = try w.store.createShape(gpa, .{ .triangle_mesh = .{ + .vertices = &steep_verts, + .indices = &ramp_idx, + } }); + var steep = api.BodyDescriptor{ + .entity = .{ .index = 63, .generation = 0 }, + .body_type = .static, + .shape = steep_mesh, + }; + steep.friction = 0.5; + steep.restitution = 0; + self.steep_slope = try w.addBody(gpa, steep); + + // THE TERRAIN IS A BOWL, and the fourth ramp is what makes it one. A + // measured problem forced it: climbing costs forward progress, so a `+x` + // leg that ends 2.2 m short of its commanded 9 m is followed by a `−x` leg + // that spends all 9, and the character drifts 2.2 m per cycle for ever. Over + // 1000 frames that is cosmetic — it still meets the ramps every cycle — but + // this scenario is an INSTRUMENT that M1.1.25 and M1.A replay, possibly at + // other frame counts, and at ten times the length the character is 55 m away + // and the terrain is never touched again. An unbounded drift in a replayed + // instrument is a latent vacuity, so the excursion is closed by geometry + // rather than by tuning the leg lengths against the climb — a number that + // would silently stop matching the moment a ramp angle changed. + // + // Mirrored winding, and it is NOT the same index list: reflecting the + // profile reverses the triangles' orientation, so reusing `ramp_idx` here + // would point both normals DOWN and the character would fall through a + // surface that looks right in the vertex table. + const back_verts = [_]Vec3{ + av3(-68, 0, -4), av3(-70, 2.5, -4), + av3(-68, 0, 4), av3(-70, 2.5, 4), + }; + const back_idx = [_]u32{ 0, 1, 2, 1, 3, 2 }; + const back_mesh = try w.store.createShape(gpa, .{ .triangle_mesh = .{ + .vertices = &back_verts, + .indices = &back_idx, + } }); + var back = api.BodyDescriptor{ + .entity = .{ .index = 64, .generation = 0 }, + .body_type = .static, + .shape = back_mesh, + }; + back.friction = 0.5; + back.restitution = 0; + self.back_slope = try w.addBody(gpa, back); + + // --- (9) the kinematic character, x = −66 ---------------------------- + var cd = api.CharacterDescriptor{ .entity = .{ .index = 60, .generation = 0 } }; + cd.position = av3(-63.5, 0, 0); + self.character = try self.chars.createCharacter(gpa, &w.store, &w.bm, cd); + + return self; + } + + pub fn deinit(self: *Scenario, gpa: std.mem.Allocator) void { + self.mobile.deinit(gpa); + self.chars.deinit(gpa); + self.world.deinit(gpa); + } + + /// Advance one tick: the character's scripted displacement, then the world. + /// + /// The character moves FIRST and by a scripted displacement rather than a + /// velocity, because `moveCharacter` takes metres and the caller owns the + /// kinematics (`engine-physics-queries.md` §1.12.1). The script is a pure + /// function of the frame index — no RNG, no state — so replaying frame `n` + /// always asks for the same metres. + pub fn step(self: *Scenario, gpa: std.mem.Allocator, frame: u32) !void { + const d = scriptedDisplacement(frame); + self.last_move = self.chars.moveCharacter( + gpa, + &self.world.bp, + &self.world.bm, + &self.world.store, + self.character, + d, + fixed_dt, + ) catch |err| switch (err) { + // A stale handle would be a defect in this file, not a condition to + // absorb; anything else is the controller reporting on the scene. + error.StaleCharacter => unreachable, + else => return err, + }; + try self.world.step(gpa); + } + + /// The character's displacement at `frame`, in metres. + /// + /// Four phases — `+x`, `+z`, `−x`, `−z` — over a period of 700 frames. **A + /// COMMANDED LOOP IS NOT A CLOSED LOOP**, and that was measured, not foreseen: + /// climbing costs forward progress, so a `+x` leg that ends 2.2 m short of the + /// metres it asked for is followed by a `−x` leg that spends all of them, and + /// the character walks away from its terrain at 2.2 m per cycle for ever. What + /// closes the excursion is GEOMETRY — the bowl of element 8 — and not the + /// symmetry of this function. The `+z`/`−z` pair does cancel, nothing blocking + /// motion along Z. + /// + /// **The `+x` leg is 300 frames and that length is load-bearing.** At 0.06 m per + /// frame it commands 18 m across a bowl 8 m wide, so the character spends most + /// of the leg PRESSED against the steep ramp — which is the only regime where + /// the slope verdict dominates the outcome. Measured: with a 150-frame leg it + /// arrived at the ramp's base with its budget spent, and a `cos_max_slope` + /// loosened enough to make that ramp walkable moved the trajectory by 0.23 m + /// instead of 1.72 m. The counter-factual test at the bottom of this file is + /// what would catch that weakening again. + /// + /// The walk is 0.06 and not 0.03 because `tryStepUp` is a per-tick sweep: at + /// 0.03 m/frame NO riser is ever climbed, at any height — swept and measured at + /// M1.1.14's review, where the scenario was found walking below the threshold of + /// its own step arm. + /// + /// The gravity term runs throughout so the character is always resolving a + /// ground contact rather than floating. A pure function of the frame index by + /// construction — no RNG, no state. + fn scriptedDisplacement(frame: u32) Vec3r { + const fall: Real = -0.02; + const walk: Real = 0.06; + return switch (frame % 700) { + 0...299 => vr(walk, fall, 0), + 300...349 => vr(0, fall, walk), + 350...649 => vr(-walk, fall, 0), + else => vr(0, fall, -walk), + }; + } +}; + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; +const trace_mod = @import("trace.zig"); +// The compared window's width, from the ONE place that defines it. A second copy +// here is how a bound drifts from the artifact it is supposed to bound. +const window_frames = @import("run.zig").window_frames; +const chain_frames = @import("run.zig").chain_frames; +const sensor_mod = @import("../../pipeline/sensor.zig"); +const EntityId = api.EntityId; // the module's own route, as `body_manager.zig` and `sensor.zig` take it + +test "scenario: builds, and every element is present" { + const gpa = testing.allocator; + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + // MOBILE = 5 stack + 2 group_a + 2 group_b + 1 slider + 1 mesh sphere + // + 1 trigger visitor + 1 lone sleeper = 13. The arithmetic is written + // out so a reader + // can check it against the constructor rather than trust the total. + // + // **THIS ASSERTION DID NOT DO WHAT ITS COMMENT CLAIMED, and M1.1.14's review + // proved it by finding the case it was written for.** It used to say it "fails + // when an element is added without being appended to `mobile`, which would + // silently shrink the continuous metric's coverage". The character was added + // and not appended — deliberately, since a virtual character has no rigid body + // to append — and this test stayed green because whoever added it updated the + // TOTAL below in step. A count pinned in the same commit as the change it is + // meant to catch catches nothing; what it really guards is arithmetic drift + // between the two numbers, which is a smaller claim and is now the one written. + // What covers the real case is the coverage test at the bottom of this file, + // which asserts on the STREAM rather than on a count. + try testing.expectEqual(@as(usize, 13), s.mobile.items.len); + // ALL BODIES = the 13 above + the half-space ground + the static mesh + the + // trigger + the riser + the THREE ramps + the character's kinematic presence + // = 21. The presence is a body like any other in the store (§1.12.2) and is + // counted here for that reason. + try testing.expectEqual(@as(u32, 21), s.world.bm.count()); + try testing.expectEqual(@as(u32, 1), s.chars.count()); + try testing.expect(s.world.sensors_on); +} + +test "scenario: steps without error, and the character resolves its ground" { + const gpa = testing.allocator; + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + var f: u32 = 0; + while (f < 120) : (f += 1) try s.step(gpa, f); + + // The controller resolved a ground contact rather than sinking: a scripted + // fall of 2 cm per tick over 120 ticks would put an unresolved character + // 2.4 m under the plane, and the terrain's lowest surface is the plane itself. + // + // The BAND is what changed at M1.1.14's review, and the reason is the whole + // point of the fix: this used to assert `|y| < 0.05`, which is only true of a + // character on FLAT GROUND. It passed for a thousand frames because the scene + // had no relief at all — the assertion was a witness to the defect rather than + // a guard against it. By frame 120 the character is partway up the walkable + // ramp, so the band is now the terrain's own vertical extent. + const pos = s.chars.get(s.character).?.position.toArray(); + try testing.expect(pos[1] > -0.05); + try testing.expect(pos[1] < 1.05); + try testing.expectEqual(api.GroundState.grounded, s.chars.get(s.character).?.reported_ground); +} + +/// `EntityId` carries no `eql`, and the comparison is written out on BOTH fields +/// rather than bitcast to the packed `u64` — the same rule the sensor dump follows, +/// field order in a packed struct being a layout accident. +fn samePair(pr: sensor_mod.EntityPair, trigger: EntityId, other: EntityId) bool { + return pr.trigger.index == trigger.index and pr.trigger.generation == trigger.generation and + pr.other.index == other.index and pr.other.generation == other.generation; +} + +/// True iff the observation fired AND fired strictly inside the compared window. +/// `null` — never fired — is false: an observation that never happened is not one +/// that happened late. +fn firstFireInside(first: ?u32, win: u32) bool { + return if (first) |fr| fr < win else false; +} + +test "scenario: every one of the nine elements actually fires" { + // THE SCOPE MEASUREMENT, and it is due BEFORE any witness is committed. A + // witness taken over a scene where the groups never meet, the sensor never + // triggers or the mesh never produces a second constraint would be perfectly + // stable and would prove nothing: the trace would agree with itself because + // nothing happened. Each assertion below names the mechanism it observes. + const gpa = testing.allocator; + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + var first_ground: ?u32 = null; + var first_stack_sleep: ?u32 = null; + var first_mesh_multi_pair: ?u32 = null; + var min_islands: usize = std.math.maxInt(usize); + var max_islands: usize = 0; + var entered: usize = 0; + var exited: usize = 0; + var first_lone_sleep: ?u32 = null; + var first_shared_island: ?u32 = null; + var first_island_change: ?u32 = null; + var char_max_y: Real = -1e9; + var char_max_x: Real = -1e9; + var stood_on_riser = false; + var stood_on_walk_slope = false; + + // THE LOOP RUNS THE SPAN THE ASSERTIONS CLAIM — `chain_frames`, not 750. The + // bound was never free to choose: the script's period is 700 frames, so the + // 400-frame window this loop originally ran is STRUCTURALLY unable to see the + // script's second half, and the riser clause failed when it was added for that + // reason (the character stands on it at frame 422, measured). 750 covered one + // period and still LIED about its own reach: the assertions below name the + // chain's 1000-frame span, so a second sensor entry or exit anywhere in 750..999 + // escaped `exactly one` entirely. The claimed scope was the correct one and the + // loop was the part that was wrong. + // + // MEASURED at the full span, and no assertion had to be relaxed to get there: + // entered = 1, exited = 1, max_x = −60.1875, max_y = 0.946324, slider v = 5.0. + // What the extra 250 frames buy is not a new observation, it is the removal of an + // unexamined gap between what is asserted and what is traversed. + var f: u32 = 0; + while (f < chain_frames) : (f += 1) { + try s.step(gpa, f); + + // (1) the half-space: a constraint EITHER of whose halves is the ground's + // handle. Both halves are tested because `pair_key` is `min << 32 | max` + // over `BodyId`s and which side the ground lands on is not ours to assume. + // (5) the mesh: two constraints sharing one pair key is the third term of + // the ordering key being needed, which only a multi-triangle contact + // produces. + var prev_key: ?u64 = null; + for (s.world.constraints.items) |c| { + const hi: BodyId = @intCast(c.pair_key >> 32); + const lo: BodyId = @intCast(c.pair_key & 0xFFFF_FFFF); + if ((hi == s.ground or lo == s.ground) and first_ground == null) first_ground = f; + if (prev_key) |k| { + // The pair must be THE MESH PAIR, by both identities. `k == pair_key` + // alone is an aggregate: it says some pair carried two constraints, and + // the claim is that the MESH did. Nothing but a multi-triangle contact + // produces it today, which is precisely the kind of tacit property this + // test has now been caught relying on three times. + const is_mesh_pair = (hi == s.mesh_body and lo == s.mesh_sphere) or + (hi == s.mesh_sphere and lo == s.mesh_body); + if (k == c.pair_key and is_mesh_pair and first_mesh_multi_pair == null) first_mesh_multi_pair = f; + } + prev_key = c.pair_key; + } + + // (2) THE STACK SLEEPS, BY IDENTITY — five named bodies, not a counter. + // THIRD INSTANCE of one class on this single test, and the previous repair + // MOVED it rather than closing it: `slept_last_tick > 0` is generic, so it + // was satisfied at frame 29 by element 7's body while the line claimed the + // stack's transition at 100. Codex measured the consequence — forbidding all + // five stack bodies to sleep left the whole suite green. + if (first_stack_sleep == null) { + for (s.stack) |b| { + if (s.world.bm.isSleeping(b) orelse false) { + first_stack_sleep = f; + break; + } + } + } + + // (7) THE LONE SLEEPER, BY IDENTITY AND BY INSTANT. Element 7 had NO + // observation of its own until Codex measured its absence: the sleep line + // above is satisfied by ANY body, so forbidding `lone_sleeper` to sleep left + // this test green. The first repair gave it an identity and left it + // accumulating over 750 frames, and Codex measured THAT too — awake for the + // compared window and asleep afterwards was green again. A test that promises + // nine proofs and delivers seven is worse than one that promises seven. + if ((s.world.bm.isSleeping(s.lone_sleeper) orelse false) and first_lone_sleep == null) first_lone_sleep = f; + + // (3) THE TWO GROUPS SHARING ONE ISLAND, by MEMBERSHIP and not by any count. + // The element lost its assertion here when the count predicate was + // unattributed — the right move, which left a hole. Coverage asks whether the + // element fires; the full SEPARATE → TOGETHER → SEPARATE sequence stays in the + // dedicated test, which is the only place it is established. + { + var ia: ?usize = null; + var ib: ?usize = null; + for (s.world.islands.islandsSlice(), 0..) |isl, idx| { + for (s.world.islands.islandMembers(isl)) |m| { + if (m == s.group_a[0]) ia = idx; + if (m == s.group_b[0]) ib = idx; + } + } + if (ia != null and ib != null and ia.? == ib.? and first_shared_island == null) first_shared_island = f; + } + + // THE ISLAND COUNT MOVES AT ALL — a coarse coverage probe and NOTHING MORE. + // It is deliberately NOT attributed to element 3 and claims NEITHER direction: + // the predicate below is `max > min`, which one variation in either sense + // satisfies, and the lone sleeper leaving the partition supplies exactly that + // on its own. The two-direction property, and its attribution to the two + // groups, live in the dedicated test that follows island MEMBERSHIP — which is + // the only place either is actually established. + const n = s.world.islands.islandsSlice().len; + if (n < min_islands) min_islands = n; + if (n > max_islands) max_islands = n; + if (max_islands > min_islands and first_island_change == null) first_island_change = f; + + // (6) the two sensor deltas. + // (6) THE SENSOR DELTAS, filtered to THE trigger and THE visitor. The raw + // set lengths are an aggregate: any trigger and any visitor would count, and + // the claim is one crossing by that body of that sensor. + const trig_e = s.world.bm.entity(s.trigger).?; + const visit_e = s.world.bm.entity(s.trigger_visitor).?; + for (s.world.sensors.entered.items) |pr| { + if (samePair(pr, trig_e, visit_e)) entered += 1; + } + for (s.world.sensors.exited.items) |pr| { + if (samePair(pr, trig_e, visit_e)) exited += 1; + } + + // (8) + (9) the terrain, accumulated over the RUN and not read at its end: + // the claim is that the character climbed, which is a property of the + // trajectory. A final-value assertion would depend on where this loop + // happens to stop — measured, it stops on the descent leg, so the first + // version of this check failed on a character that had climbed perfectly. + const cp = s.chars.get(s.character).?.position.toArray(); + if (cp[1] > char_max_y) char_max_y = cp[1]; + if (cp[0] > char_max_x) char_max_x = cp[0]; + + // WHICH BODY THE CONTROLLER SAID IT WAS STANDING ON, read from its own + // answer rather than inferred from a box drawn around each obstacle. The + // earlier version tested a position band on the riser's tread, which is a + // PROXY: it holds only while that rectangle stays correct, and a character + // that never touched the riser but drifted through the band would satisfy it. + // `ground.body` is the contact itself. + const gb = s.last_move.ground.body; + if (gb == s.step_block) stood_on_riser = true; + if (gb == s.walk_slope) stood_on_walk_slope = true; + } + + // AN OBSERVATION HAS AN INSTANT, NOT ONLY AN OBJECT — and the instant it must + // fall inside is the one its ARTIFACT is compared over. There are two spans, not + // one, and conflating them is what let an assertion be true outside everything + // verified: + // + // * the FOUR DISCRETE TRACES — island partition, sleep state, per-pair manifold + // cardinality, retained pair set — are compared over `window_frames` = 60, + // on all twelve cells. A mechanism observed only in a discrete trace MUST + // fire inside 60 or it lies outside every compared byte. + // * the CONTINUOUS CHAIN is compared over `chain_frames` = 1000. A mechanism + // carried by a body's thirteen scalars, by the character's base position or + // by the serialised sensor sets is verified anywhere in that span, and + // bounding it at 60 would be FALSE, not merely strict — the riser is stood + // on at frame 422, measured. + // + // So the five discrete-carried observations below assert a FRAME, not a boolean. + // The correction came from Codex on element 7: its former boolean accumulated + // over 750 frames while the element exists to make the sleep-state trace + // non-constant inside 60, so keeping that body awake for the compared window and + // letting it sleep later left the test green. Identity had been fixed one round + // earlier and the instant had not — the same defect, one axis over. + const win = window_frames; + try testing.expect(firstFireInside(first_ground, win)); // (1) manifold cardinality + try testing.expect(firstFireInside(first_shared_island, win)); // (3) island partition + try testing.expect(firstFireInside(first_mesh_multi_pair, win)); // (5) manifold cardinality + try testing.expect(firstFireInside(first_lone_sleep, win)); // (7) sleep state, THIS body + // The partition moves at all, inside the window. Coarse, unattributed, and + // satisfied by any single variation — the sequence on the two groups is asserted + // by its own test. + try testing.expect(firstFireInside(first_island_change, win)); + + // ELEMENT 2's SLEEP TRANSITION IS **NOT** A DISCRETE-WINDOW WITNESS, and this + // line is deliberately not bounded at 60 because the bound would fail. MEASURED + // both ways: the scene's first sleep is frame 29 and it is element 7's body; with + // element 7 forbidden to sleep the first is frame 100 — outside the window. That + // is exactly the sentence element 7's header already carried ("sleep state and + // the island partition were both CONSTANT over the compared window before it"), + // and the assertion labelled (2) had been claiming otherwise. The stack's + // transition zeroes its velocities, so it IS verified — in the CHAIN, at frame + // 100 of 1000. + try testing.expect(first_stack_sleep != null); // (2) chain-carried, by identity + + // (6) THE SENSOR DELTAS are serialised into the chain dump, so their span is + // 1000 and not 60 — measured inside it anyway, entered at frame 7 and exited at + // frame 32. Counted over the whole run because the CARDINALITY is the claim: + // exactly one crossing each way, which a window bound could not express. + try testing.expectEqual(@as(usize, 1), entered); + try testing.expectEqual(@as(usize, 1), exited); + + // (4) the slider still carries speed: it is frictionless and undamped, so a + // value far from 5 m/s would mean it hit something and stopped being the + // residual carrier this element exists to be. + const v = s.world.bm.linearVelocity(s.slider).?.toArray()[0]; + try testing.expect(v > 4.9); + + // (8) + (9) THE TERRAIN. **EACH CLAUSE ASSERTS THAT THE CHARACTER REACHED THE + // OBSTACLE, not that the obstacle exists**, and that distinction is the whole + // point: the review's finding was "no step and no slope nearby", and a step and a + // slope the character never touches satisfy those words and nothing else. + // + // It STOOD ON the riser — the controller's own `ground.body`, so `tryStepUp` + // demonstrably fired and carried it onto the tread. This is the arm the + // scenario's former 0.03 m/tick walk could not exercise at ANY riser height. + try testing.expect(stood_on_riser); + // It STOOD ON the walkable ramp, again by `ground.body`: the slope test admitted + // a 26.6° surface as ground and the character bore on it. + try testing.expect(stood_on_walk_slope); + // And it CLIMBED that ramp rather than merely brushing its foot: the ramp crests + // at y = 1 and the plane is at y = 0, so this height is unreachable from the + // plane. `ground.body` alone would be satisfied by one tick of contact at the + // very bottom. + try testing.expect(char_max_y > 0.85); + // THE STEEP RAMP, and this clause is TWO-SIDED because the one-sided form was + // satisfiable by absence — `max_x < −60` holds just as well for a character that + // never came near it. The lower bound is the arrival: the ramp's base is at + // x = −60 and a capsule of radius 0.3 held against it stands near −60.19, so + // −60.4 requires contact. The upper bound is the refusal: passing −60 would mean + // the slope test admitted a 51.3° surface, which is exactly what the + // counter-factual below makes it do, and it then reaches −58.8. + try testing.expect(char_max_x > -60.4); + try testing.expect(char_max_x < -60.0); +} + +test "scenario: the slope test DECIDES the character's trajectory, both ways" { + // THE NON-VACUITY OF ELEMENT 8, and it is the reason the two slopes exist at + // all. Element 8's clauses above prove the character climbed one ramp and was + // held by another; they do NOT prove that `cos_max_slope` is what decided it — + // a controller that climbed everything under 40° by some other rule would pass + // them identically. What discriminates is a COUNTER-FACTUAL ON THE OBJECT: + // change the cosine and nothing else, and watch the trajectory move. + // + // The bracket is `0.894` (walkable ramp) and `0.6247` (both steep ramps) around + // the default `cos(0.785) = 0.7074`. Measured, at f32, over 1000 frames: + // + // max_slope 0.400 → cos 0.9211 → max_y 0.0063 x span 2.7 m + // max_slope 0.785 → cos 0.7074 → max_y 0.9463 x span 7.7 m (the default) + // max_slope 1.200 → cos 0.3624 → max_y 2.6707 escapes the bowl + // + // Both directions, in METRES. Too large a cosine and the walkable ramp becomes + // unclimbable — the character never leaves the plane. Too small and the steep + // ramps become climbable — it crests both and leaves the terrain entirely. A + // cosine wrong in its first decimal is therefore not a rounding difference in + // this witness, it is a different scene. + const gpa = testing.allocator; + + const Outcome = struct { max_y: Real, min_x: Real, max_x: Real }; + const measure = struct { + fn run(a: std.mem.Allocator, max_slope: f32) !Outcome { + var s = try Scenario.init(a); + defer s.deinit(a); + // A SECOND character, built from the same descriptor but for its slope, + // and driven by the same script. Replacing the scenario's own would mean + // rebuilding the body order, which is part of the contract (see header). + var cd = api.CharacterDescriptor{ .entity = .{ .index = 70, .generation = 0 } }; + cd.position = av3(-63.5, 0, 0); + cd.max_slope = max_slope; + const alt = try s.chars.createCharacter(a, &s.world.store, &s.world.bm, cd); + + var out = Outcome{ .max_y = -1e9, .min_x = 1e9, .max_x = -1e9 }; + var f: u32 = 0; + while (f < 1000) : (f += 1) { + _ = s.chars.moveCharacter( + a, + &s.world.bp, + &s.world.bm, + &s.world.store, + alt, + Scenario.scriptedDisplacement(f), + fixed_dt, + ) catch unreachable; + try s.step(a, f); + const p = s.chars.get(alt).?.position.toArray(); + if (p[1] > out.max_y) out.max_y = p[1]; + if (p[0] < out.min_x) out.min_x = p[0]; + if (p[0] > out.max_x) out.max_x = p[0]; + } + return out; + } + }.run; + + const strict = try measure(gpa, 0.40); + const actual = try measure(gpa, 0.785); + const loose = try measure(gpa, 1.20); + + // A cosine STRICTER than the walkable ramp: the ramp is refused and the + // character stays on the plane. Bounds are loose by a wide margin on purpose — + // what is asserted is the SEPARATION between the three regimes, not a + // measurement, which is what keeps this test from re-pinning a value the + // solver is free to move. + try testing.expect(strict.max_y < 0.1); + try testing.expect(actual.max_y - strict.max_y > 0.5); + + // A cosine LOOSER than the steep ramps: they become walkable and the character + // crests them, which the default run never does. + try testing.expect(loose.max_y - actual.max_y > 0.5); + try testing.expect(loose.max_x > actual.max_x); + + // And the DEFAULT run stays inside the bowl the terrain forms — the property + // that keeps the instrument from drifting off its own scene when replayed at a + // longer frame count (M1.1.25, M1.A). + try testing.expect(actual.min_x > -68.5); + try testing.expect(actual.max_x < -60.0); +} + +test "scenario: the retained pair set really SHRINKS — the fourth trace is an oracle" { + // P1-5, THE NON-VACUITY OF THE FOURTH DISCRETE TRACE. The retained pair set is + // one of the four invariants every CI cell compares, and `trace.zig` states the + // hazard on itself: over a set that can only GROW, a trace agrees with itself by + // accumulation and proves nothing. The harness was pruned at M1.1.14 precisely + // so that stops being true — but the pruning being IMPLEMENTED and the canonical + // scenario EXERCISING it are two different claims, and `solver_test.zig`'s + // generic departure test establishes only the first. This establishes the second. + // + // ASSERTED ON THE SET, NEVER ON ITS CARDINALITY, and that distinction is + // measured rather than stylistic: the removal at frame 196 is followed by an + // ADDITION as the same sphere reaches the ground plane, so the size returns to + // what it was. A size-based probe sees the dip here only because the two events + // land on different ticks — had they coincided it would have reported nothing at + // all while the removal happened. What is required is that a key present once be + // absent later. + // + // WHAT LEAVES, and why it is structural rather than incidental: the pair is the + // static `MeshShape` and the frictionless sphere crossing it. The sphere carries + // a fixed 3 m/s, leaves the mesh at x = 65 around frame 180, and its fat AABB + // separates from the mesh's around frame 196 — permanently, there being nothing + // to bring it back. The event is a consequence of element 5's design, so it + // cannot quietly stop happening while that element still does what it is for. + const gpa = testing.allocator; + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + var seen: std.ArrayListUnmanaged(u64) = .empty; + defer seen.deinit(gpa); + var removed: std.ArrayListUnmanaged(u64) = .empty; + defer removed.deinit(gpa); + + var max_live: usize = 0; + var f: u32 = 0; + while (f < 400) : (f += 1) { + try s.step(gpa, f); + const live = s.world.active.items; + if (live.len > max_live) max_live = live.len; + + // Anything seen before and not live now has been pruned. Recorded once. + for (seen.items) |k| { + var still = false; + for (live) |k2| { + if (k == k2) { + still = true; + break; + } + } + if (still) continue; + var already = false; + for (removed.items) |k2| { + if (k == k2) { + already = true; + break; + } + } + if (!already) try removed.append(gpa, k); + } + for (live) |k| { + var known = false; + for (seen.items) |k2| { + if (k == k2) { + known = true; + break; + } + } + if (!known) try seen.append(gpa, k); + } + } + + // POSITIVE WITNESS FIRST. "A key disappeared" is satisfied by a set that was + // empty throughout, which is the vacuity this whole test exists against. + try testing.expect(max_live >= 2); + try testing.expect(seen.items.len >= 2); + + // THE REMOVAL. At least one pair the harness held was pruned. + try testing.expect(removed.items.len >= 1); + + // AND IT IS THE PAIR THE ANALYSIS NAMES, not merely some pair. Without this the + // test would pass on a removal caused by anything at all — a body recycled, a + // key mis-sorted — and would stop being evidence about pruning. The key is + // `min << 32 | max` over `BodyId`s, built here from the handles rather than + // hard-coded, since a `BodyId` is generational and not a slot number. + const lo = @min(s.mesh_body, s.mesh_sphere); + const hi = @max(s.mesh_body, s.mesh_sphere); + const want = (@as(u64, lo) << 32) | hi; + var found = false; + for (removed.items) |k| { + if (k == want) { + found = true; + break; + } + } + try testing.expect(found); +} + +test "the two groups are separate, then one island, then separate again" { + // P1-2, SECOND ROUND. The first version put two TRUE witnesses side by side with + // nothing joining them: `saw_cross_pair` on one hand, and `fell`/`rose` on the + // GLOBAL island count on the other. The sleeper already supplies a fall, any + // transition anywhere can supply a rise, and NOTHING tied either to + // `group_a`/`group_b` — so the test passed even if the groups stayed merged. A + // conjunction of facts is not the fact sought, which is the same class as the + // fourth trace agreeing with itself. + // + // The property the scenario ANNOUNCES is a sequence on those two groups: + // SEPARATE, then ONE ISLAND, then SEPARATE again. The global count is at best a + // consequence of it. So the membership is followed directly — which island holds + // `group_a[0]`, which holds `group_b[0]` — and the three phases are required in + // order. + // + // This REPLACES the count-based clauses rather than sitting beside them: + // juxtaposing a targeted assertion and a vague one strengthens nothing when + // nothing conjoins them, and that juxtaposition is what produced two parallel + // witnesses in the first place. `max_islands > min_islands` survives in the + // coverage test, where it answers a different question — that the partition moves + // at all. + const gpa = testing.allocator; + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + // MEASURED at the shipped tuning: apart from frame 0, merged at frame 15, apart + // again at frame 21 — six frames of contact, all three inside the window. + // + // The phase machine: 0 = waiting for them apart, 1 = seen apart, 2 = seen merged, + // 3 = seen apart again. It only ever advances, so a single frame in each state in + // the right order is what satisfies it — and a scene that merges and stays merged + // stops at 2. + var phase: u8 = 0; + var saw_cross_pair = false; + + var f: u32 = 0; + while (f < 60) : (f += 1) { + try s.step(gpa, f); + + // Which island each group sits in, by MEMBERSHIP and not by count. Null when + // the body is in no island — asleep, or not awake yet — and that case + // advances nothing rather than being read as "separate". + var isl_a: ?usize = null; + var isl_b: ?usize = null; + for (s.world.islands.islandsSlice(), 0..) |isl, idx| { + for (s.world.islands.islandMembers(isl)) |m| { + if (m == s.group_a[0]) isl_a = idx; + if (m == s.group_b[0]) isl_b = idx; + } + } + if (isl_a == null or isl_b == null) continue; + + const together = isl_a.? == isl_b.?; + switch (phase) { + 0 => if (!together) { + phase = 1; + }, + 1 => if (together) { + phase = 2; + }, + 2 => if (!together) { + phase = 3; + }, + else => {}, + } + + for (s.world.constraints.items) |c| { + const hi: BodyId = @intCast(c.pair_key >> 32); + const lo: BodyId = @intCast(c.pair_key & 0xFFFF_FFFF); + const a_side = hi == s.group_a[0] or hi == s.group_a[1] or lo == s.group_a[0] or lo == s.group_a[1]; + const b_side = hi == s.group_b[0] or hi == s.group_b[1] or lo == s.group_b[0] or lo == s.group_b[1]; + if (a_side and b_side) saw_cross_pair = true; + } + } + + // THE FULL SEQUENCE ON THESE TWO GROUPS. Phase 2 alone would be a merge-only + // scene; phase 3 is what proves the partition splits as well as joins, and both + // are read off the membership of these bodies rather than off a global count that + // the sleeper also moves. + errdefer std.debug.print("group island phase reached: {d} of 3\n", .{phase}); + try testing.expectEqual(@as(u8, 3), phase); + + // And the merge was a real CONTACT between the two groups, named on the bodies: + // a shared island could in principle come from a chain through a third body. + try testing.expect(saw_cross_pair); +} + +test "each sensor set reaches the CHAIN DUMP, at a frame where it is non-empty" { + // P1, THIRD FORMULATION OF ONE QUESTION ON ONE ELEMENT. First the sensor reached + // no artifact at all. Then the test was gated on `current`, so it could not see a + // missing delta. Now the previous fix tested `dumpSensorSets` AGAINST ITSELF: it + // compared two direct calls, `full` was produced in the loop and entered no + // comparison, and the one integration check ran after the 60 frames — where all + // three sets are empty, so it compared emptiness. Making `dumpState` emit only + // `current` left the whole Forge suite green. + // + // What that proved: the function can serialise three sets. What has to be proved: + // the three arrive in the COMPARED artifact. So the assertion is on the SUFFIX of + // what `dumpState` produces, at a frame where the set in question is non-empty. + // + // THE REFERENCE USES A LITERAL MASK AND NOT `all_sensor_sets`, which is the trap + // this correction had to avoid: with the reference reading the same constant the + // production reads, a counter-factual on production moves BOTH sides and the test + // fires for the wrong reason. The literal keeps the two independent. + const gpa = testing.allocator; + var s = try Scenario.init(gpa); + defer s.deinit(gpa); + + const names = [_][]const u8{ "current", "entered", "exited" }; + var frames_seen = [_]u32{ 0, 0, 0 }; + var in_chain = [_]bool{ false, false, false }; + var contributes = [_]bool{ false, false, false }; + + var full: std.ArrayListUnmanaged(u8) = .empty; + defer full.deinit(gpa); + var record: std.ArrayListUnmanaged(u8) = .empty; + defer record.deinit(gpa); + var without: std.ArrayListUnmanaged(u8) = .empty; + defer without.deinit(gpa); + + var f: u32 = 0; + while (f < 60) : (f += 1) { + try s.step(gpa, f); + const lens = [_]usize{ + s.world.sensors.current.items.len, + s.world.sensors.entered.items.len, + s.world.sensors.exited.items.len, + }; + for (lens, 0..) |n, k| { + if (n == 0) continue; + frames_seen[k] += 1; + + full.clearRetainingCapacity(); + record.clearRetainingCapacity(); + without.clearRetainingCapacity(); + + // The REAL path. + try trace_mod.dumpState(&s, gpa, &full); + // The complete record, from a LITERAL mask. + try trace_mod.dumpSensorSets(&s, gpa, &record, .{ true, true, true }); + // The same record with this one set emptied — which is what makes the + // suffix test below specific to set `k` rather than to the record's shape. + var mask: trace_mod.SensorSetMask = .{ true, true, true }; + mask[k] = false; + try trace_mod.dumpSensorSets(&s, gpa, &without, mask); + + // (a) THE CHAIN DUMP CARRIES THE COMPLETE RECORD at this frame. + if (std.mem.endsWith(u8, full.items, record.items)) in_chain[k] = true; + // (b) AND THIS SET IS PART OF WHAT MAKES IT COMPLETE — otherwise (a) would + // hold just as well for a record that never mentions set `k`. + if (!std.mem.eql(u8, record.items, without.items)) contributes[k] = true; + } + } + + for (names, frames_seen, in_chain, contributes) |n, seen, chained, contrib| { + errdefer std.debug.print( + "sensor set `{s}`: non-empty on {d} frame(s), in dumpState={}, contributes={}\n", + .{ n, seen, chained, contrib }, + ); + // Non-vacuity first: a set that is never non-empty inside the window makes + // both clauses below meaningless. + try testing.expect(seen >= 1); + try testing.expect(chained); + try testing.expect(contrib); + } +} diff --git a/src/modules/forge/forge_3d/tests/determinism/trace.zig b/src/modules/forge/forge_3d/tests/determinism/trace.zig new file mode 100644 index 00000000..d4fd25b0 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/determinism/trace.zig @@ -0,0 +1,443 @@ +//! The three artifact kinds of M1.1.14, and nothing else. +//! +//! Everything here is a pure function of a `Scenario` at a tick. No allocation +//! decision, no ordering and no threshold is taken from the engine's internals +//! by chance: each is stated, and the ones that are already normative elsewhere +//! are REUSED rather than re-invented — the deviation metric below is the sleep +//! criterion's own bound (`engine-physics-solver.md` §1.8.3), for the reason +//! that two formulas for one geometric fact is the defect class this repository +//! names. +//! +//! **Encoding: explicit little-endian, always.** Every scalar goes through +//! `std.mem.writeInt` on the integer of its own width, floats via `@bitCast` +//! first. A witness is committed in tree and read back on three targets, so the +//! host's byte order must not appear in it — and `@bitCast` rather than a +//! decimal rendering because the contract is BINARY identity and a decimal round +//! trip is exactly where it would be lost. +//! +//! **The hash is SHA-256, and that is a decision.** A witness has to survive a +//! compiler patch bump: a standardised digest is defined by its specification, +//! whereas a fast non-cryptographic hash is defined by an implementation that is +//! free to change under us. The chain is not defending against an adversary — it +//! is defending against an unannounced change of algorithm. + +const std = @import("std"); +const config = @import("../../config.zig"); +const api = @import("weld_forge"); +const Scenario = @import("scenario.zig").Scenario; +const sensor_mod = @import("../../pipeline/sensor.zig"); + +const Real = config.Real; +const BodyId = api.BodyId; + +/// The integer of the solver scalar's own width — what a `Real` is `@bitCast` to +/// before it is written. +const RealBits = if (Real == f32) u32 else u64; + +/// The digest the chain carries. +pub const Hash = std.crypto.hash.sha2.Sha256; +/// Length of one chain link, in bytes. +pub const digest_len = Hash.digest_length; + +/// Threshold factor of the continuous deviation metric, from +/// `engine-phase-1-criteria.md` C1.1: the divergence frame is the first where a +/// body's deviation exceeds `1e-4 × body scale`. +pub const divergence_factor: f64 = 1.0e-4; + +// --- primitive encoders ------------------------------------------------------- + +fn putU32(out: *std.ArrayListUnmanaged(u8), gpa: std.mem.Allocator, v: u32) !void { + var buf: [4]u8 = undefined; + std.mem.writeInt(u32, &buf, v, .little); + try out.appendSlice(gpa, &buf); +} + +fn putU64(out: *std.ArrayListUnmanaged(u8), gpa: std.mem.Allocator, v: u64) !void { + var buf: [8]u8 = undefined; + std.mem.writeInt(u64, &buf, v, .little); + try out.appendSlice(gpa, &buf); +} + +fn putReal(out: *std.ArrayListUnmanaged(u8), gpa: std.mem.Allocator, v: Real) !void { + const bits: RealBits = @bitCast(v); + var buf: [@sizeOf(RealBits)]u8 = undefined; + std.mem.writeInt(RealBits, &buf, bits, .little); + try out.appendSlice(gpa, &buf); +} + +// --- (a) the continuous state dump ------------------------------------------- + +/// Append the canonical binary state of every MOBILE body at the current tick. +/// +/// Mobile only, and the exclusion is the brief's: a static shape with an +/// unbounded local AABB has no meaningful scale, and forcing one into a metric +/// that weights by radius would mean inventing that radius. +/// +/// Thirteen scalars per body — position, rotation, linear and angular velocity — +/// in `Scenario.mobile` order, which is creation order. Velocity is in the dump +/// and not only pose, deliberately: two runs can agree on every position for +/// several ticks while their velocities have already parted, and a pose-only +/// dump would report the divergence late. +pub fn dumpState(s: *const Scenario, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8)) !void { + for (s.mobile.items) |id| { + const p = s.world.bm.position(id) orelse continue; + const q = s.world.bm.rotation(id) orelse continue; + const lv = s.world.bm.linearVelocity(id) orelse continue; + const av = s.world.bm.angularVelocity(id) orelse continue; + for (p.toArray()) |c| try putReal(out, gpa, c); + try putReal(out, gpa, q.x); + try putReal(out, gpa, q.y); + try putReal(out, gpa, q.z); + try putReal(out, gpa, q.w); + for (lv.toArray()) |c| try putReal(out, gpa, c); + for (av.toArray()) |c| try putReal(out, gpa, c); + } + + // THE CHARACTER, and its absence here was a defect and not an omission of + // detail. `mobile` holds rigid bodies; a virtual character owns no simulated + // body, so it entered NO artifact — the controller ran for 1000 frames, swept, + // depenetrated, classified its ground, and every bit of that was discarded. + // The scenario header listed it as an element the whole time — numbered 7 of 7 + // then, 9 of 9 now that the sleeper and the split terrain entry exist. + // + // ITS OWN STATE, never its presence body's. The presence is a broadphase + // artifact whose pose is the capsule's CENTRE and whose velocities are always + // zero; the authoritative quantity is the BASE position the store holds, and + // routing through the body would serialise a derived value plus six zeros. + // + // THE GROUND VERDICT IS WHAT MAKES `cos_max_slope` OBSERVABLE, and therefore + // the deterministic cosine. Written as an integer-valued `Real` so the record + // stays one uniform stream of scalars: the three values are exactly + // representable at both precisions, so the encoding costs no information. + const ch = s.chars.get(s.character) orelse return; + for (ch.position.toArray()) |c| try putReal(out, gpa, c); + try putReal(out, gpa, @floatFromInt(@intFromEnum(ch.reported_ground))); + + // THE SENSOR STATE, and its absence was a LEVEL-1 coverage hole rather than a + // level-2 one. A trigger resolves no impulse: `current`, `entered` and `exited` + // can all diverge without displacing a single body, so the chain, the four + // discrete traces and the reference window would every one of them stay + // identical while the sensor pass disagreed between two machines. The scenario + // listed the sensor as an element the whole time, and its output reached NO + // artifact — presence in the scene mistaken for coverage. + // + // All THREE sets, and each for its own reason. `current` is the membership §1.13 + // makes the source of truth, since the bus drops its oldest entry on saturation + // and a set rebuilt from the flow would be wrong on the first one. `entered` and + // `exited` are the two deltas, and a state that agreed while a delta did not + // would be a real divergence this dump would hide. + // + // The identity is written INDEX THEN GENERATION, both halves: a recycled slot + // reuses the index, so an index-only record would read two different entities as + // one. Length-prefixed, like every other length in this file. + try dumpSensorSets(s, gpa, out, all_sensor_sets); +} + +/// Which of the three sensor sets a dump carries. Index order is `current`, +/// `entered`, `exited` — the order `dumpSensorSets` writes them in. +pub const SensorSetMask = [3]bool; + +/// Production always carries all three. +pub const all_sensor_sets: SensorSetMask = .{ true, true, true }; + +/// Append the three sensor sets, each one either in full or AS AN EMPTY SET. +/// +/// **A SUPPRESSED SET IS WRITTEN AS LENGTH ZERO, NEVER OMITTED, and that is what +/// makes the test above it discriminate an INSTANT rather than a shape.** Omitting +/// a set removes its length prefix too, so the bytes would differ even at a frame +/// where the set is legitimately empty — and a probe that changes the output +/// unconditionally proves nothing about the frame it was evaluated at. Written as +/// length zero, a suppression is invisible exactly when the set is empty and +/// visible exactly when it is not. +/// +/// The mask exists for the test and production passes `all_sensor_sets`; there is +/// one implementation, so the thing tested is the thing shipped. +pub fn dumpSensorSets( + s: *const Scenario, + gpa: std.mem.Allocator, + out: *std.ArrayListUnmanaged(u8), + include: SensorSetMask, +) !void { + const sets = [_][]const sensor_mod.EntityPair{ + s.world.sensors.current.items, + s.world.sensors.entered.items, + s.world.sensors.exited.items, + }; + for (sets, include) |set, on| { + const n: usize = if (on) set.len else 0; + try putU32(out, gpa, @intCast(n)); + if (!on) continue; + for (set) |pair| { + try putU32(out, gpa, pair.trigger.index); + try putU32(out, gpa, pair.trigger.generation); + try putU32(out, gpa, pair.other.index); + try putU32(out, gpa, pair.other.generation); + } + } +} + +/// The rolling per-frame hash chain: `h₀` is all zeros, `hₙ = H(hₙ₋₁ ‖ dumpₙ)`. +/// +/// Chained rather than a hash of the concatenation, because a chain lets a +/// mismatch be located: comparing link by link against a committed witness names +/// the FIRST differing frame, and "the outputs differ" is not a diagnosis. +pub const Chain = struct { + digest: [digest_len]u8 = @splat(0), + + pub fn advance(self: *Chain, frame_bytes: []const u8) void { + var h = Hash.init(.{}); + h.update(&self.digest); + h.update(frame_bytes); + h.final(&self.digest); + } +}; + +// --- (b) the four discrete traces -------------------------------------------- + +/// Append the four discrete traces of the current tick, raw and unhashed. +/// +/// Raw because these are the LEVEL-2 artifact: every cell compares them, ISA +/// included, and a mismatch has to say WHICH invariant moved. A digest would +/// collapse four independent claims into one bit. +/// +/// All four are derived from integers — handles, counts, keys — which is what +/// makes them ISA-independent by construction (`ARCH-031`, "ce que l'invariant +/// ne promet pas"). Each is length-prefixed so the reader never has to infer a +/// boundary from content. +pub fn dumpDiscrete(s: *const Scenario, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8)) !void { + // (1) ISLAND PARTITION. The rank is the smallest member `BodyId` (§1.8.1) and + // `islandMembers` returns them ascending, so `members[0]` IS the rank — read + // rather than recomputed, so a change in either cannot pass unnoticed. + const islands = s.world.islands.islandsSlice(); + try putU32(out, gpa, @intCast(islands.len)); + for (islands) |isl| { + const members = s.world.islands.islandMembers(isl); + try putU32(out, gpa, @intCast(members.len)); + for (members) |m| try putU32(out, gpa, m); + } + + // (2) SLEEP STATE of every mobile body, in creation order. The state and not + // the transition: a transition is a difference of two states, so recording + // the state records the transitions too and cannot disagree with itself. + try putU32(out, gpa, @intCast(s.mobile.items.len)); + for (s.mobile.items) |id| { + const sleeping = s.world.bm.isSleeping(id) orelse false; + try putU32(out, gpa, @intFromBool(sleeping)); + } + + // (3) PER-PAIR MANIFOLD CARDINALITY, as `(pair_key, subshape_id, count)` + // triples in constraint order — which is the total order + // `(island rank, pair_key, subshape_id)` the solver resolves in (§1.8.1), so + // this trace also witnesses that order and not only the cardinalities. + try putU32(out, gpa, @intCast(s.world.constraints.items.len)); + for (s.world.constraints.items) |c| { + try putU64(out, gpa, c.pair_key); + try putU32(out, gpa, c.subshape_id); + try putU32(out, gpa, c.count); + } + + // (4) THE RETAINED PAIR SET, sorted, as the harness holds it. This is the one + // that needed the M1.1.14 pruning fix to be an oracle at all: over a set that + // can only grow, a trace agrees with itself by accumulation. + try putU32(out, gpa, @intCast(s.world.active.items.len)); + for (s.world.active.items) |k| try putU64(out, gpa, k); +} + +// --- (c) the reference window ------------------------------------------------- + +/// Append the raw poses of every mobile body — the artifact the ARM64 cell reads +/// to compute its divergence frame. +/// +/// Pose only, no velocity: this one is not a bit-exactness witness but the input +/// to a CONTINUOUS metric, and that metric is defined on configuration. +pub fn dumpPoses(s: *const Scenario, gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8)) !void { + for (s.mobile.items) |id| { + const p = s.world.bm.position(id) orelse continue; + const q = s.world.bm.rotation(id) orelse continue; + for (p.toArray()) |c| try putReal(out, gpa, c); + try putReal(out, gpa, q.x); + try putReal(out, gpa, q.y); + try putReal(out, gpa, q.z); + try putReal(out, gpa, q.w); + } +} + +/// Scalars one body contributes to a pose record: 3 position + 4 rotation. +pub const pose_scalars_per_body: usize = 7; + +/// Whether any body's deviation from `reference` exceeds `1e-4 × body scale`. +/// +/// The per-body deviation is `‖Δx‖ + 2·r·‖vec(Δq)‖`, which is NOT a new formula: +/// it is the sleep criterion's own conservative displacement bound +/// (`engine-physics-solver.md` §1.8.3), where `2·‖vec(Δq)‖` is the exact +/// `2·sin(θ/2)` chord — trig-free, as the determinism contract requires — and the +/// sum is a triangle bound that MAJORISES the displacement of every material +/// point. Reusing it is the point: two formulas for one geometric fact is how +/// two sources come to disagree about it. +/// +/// `r` is the body's `sleep_radius`, the distance from its centre to the far +/// corner of its local AABB, computed once at creation. It is the engine's own +/// notion of body scale, so the metric invents no constant. +pub fn deviationExceeded(s: *const Scenario, reference: []const u8) bool { + const stride = @sizeOf(RealBits); + var off: usize = 0; + for (s.mobile.items) |id| { + if (off + pose_scalars_per_body * stride > reference.len) return false; + const p = s.world.bm.position(id) orelse continue; + const q = s.world.bm.rotation(id) orelse continue; + const r = s.world.bm.sleepRadius(id) orelse continue; + + var ref: [pose_scalars_per_body]Real = undefined; + for (&ref, 0..) |*v, i| { + const bits = std.mem.readInt(RealBits, reference[off + i * stride ..][0..stride], .little); + v.* = @bitCast(bits); + } + off += pose_scalars_per_body * stride; + + const cur = p.toArray(); + var dx: Real = 0; + for (0..3) |i| { + const d = cur[i] - ref[i]; + dx += d * d; + } + // The relative rotation, through the SHARED `Quat` operations rather than + // an expanded Hamilton product written here. An expansion would be a + // second copy of a formula `foundation/math` already owns, and the sleep + // criterion this metric reuses is expressed on exactly this quantity. + const q_ref = config.Quatr{ .x = ref[3], .y = ref[4], .z = ref[5], .w = ref[6] }; + const dq_quat = q.mul(q_ref.conjugate()); + const dq = @sqrt(dq_quat.x * dq_quat.x + dq_quat.y * dq_quat.y + dq_quat.z * dq_quat.z); + + if (bodyExceeds(@sqrt(dx), dq, r)) return true; + } + return false; +} + +/// Whether ONE body's deviation exceeds its own threshold. +/// +/// Extracted so the SHAPE OF THE THRESHOLD is testable in isolation, and that is +/// not tidiness. C1.1 says `1e-4 × body scale`, so the comparison is PER BODY +/// against that body's own `r`. An absolute threshold would pass on the +/// canonical scenario — its THIRTEEN mobile bodies are all of comparable size — +/// and would break silently the day a body of another scale entered the scene. +/// That is the "assertion valid only through a tacit property of its fixture" +/// class the brief names, and it becomes undetectable once a witness is +/// committed over the scene that hides it. Hence the two-scale test below, which +/// an absolute form cannot pass. +/// +/// `translation` is `‖Δx‖`, `rotation_chord` is `‖vec(Δq)‖`, `r` the body's +/// `sleep_radius`. +pub fn bodyExceeds(translation: Real, rotation_chord: Real, r: Real) bool { + const deviation = translation + 2 * r * rotation_chord; + return @as(f64, deviation) > divergence_factor * @as(f64, r); +} + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; + +test "the divergence threshold scales WITH the body, not against a constant" { + // THE DISCRIMINATING TEST, and the one an absolute threshold cannot pass. + // + // Two bodies three orders of magnitude apart in scale, each given a + // translation of exactly half its own threshold and then of twice it. A + // per-body form answers `false` then `true` for BOTH. Any absolute + // threshold — whatever constant it picks — necessarily answers the same for + // the two rows of one of the two columns, because the same displacement is + // above the constant for one body and below it for the other. + const small: Real = 0.01; // a 1 cm body + const large: Real = 10.0; // a 10 m body + + for ([_]Real{ small, large }) |r| { + const threshold: Real = @floatCast(divergence_factor * @as(f64, r)); + try testing.expect(!bodyExceeds(threshold * 0.5, 0, r)); + try testing.expect(bodyExceeds(threshold * 2.0, 0, r)); + } + + // And the cross-check that names the failure mode explicitly: the LARGE + // body's half-threshold displacement is far ABOVE the small body's whole + // threshold. A shared constant sized for either one misclassifies the other, + // and this line is what makes that arithmetic visible rather than implied. + const large_half: Real = @floatCast(divergence_factor * @as(f64, large) * 0.5); + try testing.expect(@as(f64, large_half) > divergence_factor * @as(f64, small)); + try testing.expect(!bodyExceeds(large_half, 0, large)); + try testing.expect(bodyExceeds(large_half, 0, small)); +} + +test "the rotation term is weighted by the body radius" { + // The second half of "weighting translation and rotation by the body radius". + // With zero translation, the same angular chord must clear the threshold for + // a body of any radius — the `2·r` numerator and the `1e-4·r` denominator + // cancel — so the predicate is a pure comparison of the chord against + // `1e-4/2`. Pinned because dropping the `r` from EITHER side would leave the + // translation tests above green while silently changing the rotation + // criterion by three orders of magnitude between the two bodies. + const chord_below: Real = @floatCast(divergence_factor / 2 * 0.5); + const chord_above: Real = @floatCast(divergence_factor / 2 * 2); + for ([_]Real{ 0.01, 1.0, 10.0 }) |r| { + try testing.expect(!bodyExceeds(0, chord_below, r)); + try testing.expect(bodyExceeds(0, chord_above, r)); + } +} + +test "the character IS in the continuous state, and the proof is a discrimination" { + // WHY THIS TEST EXISTS. Until M1.1.14's review the character reached NO + // artifact: `dumpState` walked `s.mobile`, which holds rigid bodies, and a + // virtual character owns none — so the controller ran a thousand frames and its + // whole output was discarded. Adding it to the dump is one line, and one line + // is exactly what a later refactor removes without noticing. + // + // A LENGTH ASSERTION WOULD NOT DO. Counting scalars would pass on a dump that + // appended four zeros, or the wrong character, or the same body twice. What is + // asserted instead is a DISCRIMINATION on the object: move the character and + // NOTHING else, and the stream must change. If the character is not in it, the + // two dumps are byte-identical and this test fails — which is the state `main` + // was in when it was written. + const gpa = testing.allocator; + + var a = try Scenario.init(gpa); + defer a.deinit(gpa); + var b = try Scenario.init(gpa); + defer b.deinit(gpa); + + // Both worlds are stepped identically, so every RIGID body agrees bit for bit. + var f: u32 = 0; + while (f < 20) : (f += 1) { + try a.step(gpa, f); + try b.step(gpa, f); + } + + var da: std.ArrayListUnmanaged(u8) = .empty; + defer da.deinit(gpa); + var db: std.ArrayListUnmanaged(u8) = .empty; + defer db.deinit(gpa); + try dumpState(&a, gpa, &da); + try dumpState(&b, gpa, &db); + + // Control first: identical scenarios give identical dumps. Without this the + // discrimination below could be passing for any reason at all. + try testing.expectEqualSlices(u8, da.items, db.items); + + // Now move ONLY the character in `b`. A teleport writes the character's own + // position and its presence body's pose; the presence is not in `mobile`, so no + // rigid-body record can carry this change. + const moved = a.chars.get(a.character).?.position.add(.{ .data = .{ 0.5, 0, 0 } }); + try b.chars.setCharacterPosition(gpa, &b.world.bp, &b.world.bm, &b.world.store, b.character, moved); + + db.clearRetainingCapacity(); + try dumpState(&b, gpa, &db); + try testing.expect(!std.mem.eql(u8, da.items, db.items)); + + // And the ground VERDICT is in the stream too, which is what carries + // `cos_max_slope` into the witness. `setCharacterPosition` invalidates the + // verdict to `.in_air` by contract (§1.12.8), so this second discrimination + // isolates the verdict field: the position is restored to its original value, + // leaving the verdict as the only difference left. + try b.chars.setCharacterPosition(gpa, &b.world.bp, &b.world.bm, &b.world.store, b.character, a.chars.get(a.character).?.position); + try testing.expectEqual(api.GroundState.in_air, b.chars.get(b.character).?.reported_ground); + try testing.expectEqual(api.GroundState.grounded, a.chars.get(a.character).?.reported_ground); + + db.clearRetainingCapacity(); + try dumpState(&b, gpa, &db); + try testing.expect(!std.mem.eql(u8, da.items, db.items)); +} diff --git a/src/modules/forge/forge_3d/tests/determinism/witness.zig b/src/modules/forge/forge_3d/tests/determinism/witness.zig new file mode 100644 index 00000000..c5ccd016 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/determinism/witness.zig @@ -0,0 +1,306 @@ +//! The committed witnesses, and the comparison that makes them mean something. +//! +//! A witness nobody reads is a file, not a guarantee. Until this module existed +//! the entry point's own doc comment said it would "compare against committed +//! witnesses" while the code below it did no such thing — a text asserting more +//! than its code does, which this repository treats as a defect of the same class +//! as a wrong line of code. +//! +//! WHAT IS COMPARED WHERE, and the asymmetry is the contract rather than an +//! implementation convenience (`engine-phase-1-criteria.md` C1.1): +//! +//! - the CONTINUOUS CHAIN is level 1, bit-exactness at identical ISA, build and +//! configuration. **It is COMPARED ON EVERY HOST and GATED only where level 1 +//! applies** — this changed at the milestone's close and the earlier text, which +//! said it was compared on x86_64 only, is superseded. On a non-level-1 host the +//! result is REPORTED and never gated, exactly like the divergence frame: its +//! REGRESSION is the signal, never its value. The old outright skip discarded the +//! strongest signal available, and measured at the close the eight witnesses are +//! BIT-IDENTICAL between `ubuntu-24.04` and aarch64-macOS, the chains included — +//! a MEASURED property of a pinned arithmetic, dated, and NOT a promotion of +//! level 3, which C1.1 still places out of Phase 1. +//! - the FOUR DISCRETE TRACES are level 2 point 1, compared by EVERY cell, ISA +//! included. They are derived from integers — handles, counts, keys — so they +//! are ISA-independent by construction, and that is precisely why a mismatch +//! between two cells of the same precision is a finding whichever axis it +//! comes from. +//! - the REFERENCE WINDOW is not a pass/fail witness at all. It is the input to +//! the continuous divergence metric, and its value is a measurement whose +//! REGRESSION is the signal. +//! +//! WHY THE READER PARSES INSTEAD OF CARRYING SIDE DATA. The discrete stream is +//! length-prefixed at every level exactly so a reader never has to infer a +//! boundary from content (`trace.dumpDiscrete`). Recording frame offsets beside +//! the bytes would put the same structure in two places, and the committed file +//! would then depend on a second artifact to be readable at all. Parsing uses the +//! format for what it was designed for, and it works identically on a fresh run +//! and on a witness produced eight months earlier by another machine. +//! +//! A MISMATCH NAMES A FRAME AND A TRACE. "The outputs differ" is not a diagnosis: +//! the brief requires the failure to name the first differing frame index, and for +//! the discrete side there are four independent claims per frame, so collapsing +//! them would throw away the half of the answer that says WHICH invariant moved. + +const std = @import("std"); +const builtin = @import("builtin"); +const config = @import("../../config.zig"); + +/// The precision half of a witness key. `-Dphysics_f64` changes every byte. +pub const precision_tag = if (config.Real == f32) "f32" else "f64"; +/// The optimize half, carried by the continuous chain alone. +pub const mode_tag = @tagName(builtin.mode); + +/// Whether the continuous chain applies to this build (see the header). +pub const chain_applies = builtin.cpu.arch == .x86_64; + +/// Whether a chain witness exists for this optimize mode. +/// +/// Only Debug and ReleaseSafe are generated, because those are the two modes the +/// matrix builds. A ReleaseFast build is legitimate and simply has no witness to +/// compare against; saying so is better than embedding a file that is not there. +pub const chain_witness_exists = builtin.mode == .Debug or builtin.mode == .ReleaseSafe; + +/// The committed 1000-frame hash chain for this (precision, mode), if one exists. +pub const chain: ?[]const u8 = switch (builtin.mode) { + .Debug => @embedFile("witnesses/continuous-chain-" ++ precision_tag ++ "-Debug.bin"), + .ReleaseSafe => @embedFile("witnesses/continuous-chain-" ++ precision_tag ++ "-ReleaseSafe.bin"), + else => null, +}; + +/// The committed 60-frame discrete traces for this precision. +pub const discrete: []const u8 = @embedFile("witnesses/discrete-" ++ precision_tag ++ ".bin"); + +/// The committed 60-frame reference window for this precision. +pub const window: []const u8 = @embedFile("witnesses/reference-window-" ++ precision_tag ++ ".bin"); + +/// The four discrete traces, in the order `trace.dumpDiscrete` writes them. +pub const Trace = enum { + island_partition, + sleep_state, + manifold_cardinality, + retained_pairs, + + pub fn label(self: Trace) []const u8 { + return switch (self) { + .island_partition => "island partition", + .sleep_state => "sleep state", + .manifold_cardinality => "per-pair manifold cardinality", + .retained_pairs => "retained pair set", + }; + } +}; + +/// Where a comparison first disagreed. +pub const Mismatch = struct { + frame: u32, + /// Null for the continuous chain, which carries no sub-claim. + trace: ?Trace = null, +}; + +fn readU32(b: []const u8, off: usize) !u32 { + if (off + 4 > b.len) return error.TruncatedWitness; + return std.mem.readInt(u32, b[off..][0..4], .little); +} + +/// The end offset of each of the four traces of the frame starting at `off`. +/// +/// Walks the length prefixes rather than trusting a stride. The discrete frame is +/// NOT fixed-size — island count, constraint count and retained-set size all move +/// with the scene — and the 400 bytes per frame this scenario happens to produce +/// is a property of the scene, not of the format. Reading it as a stride would +/// work on this witness and silently mis-locate every mismatch on the next one. +pub fn frameSpans(b: []const u8, off: usize) !struct { ends: [4]usize } { + var p = off; + + // (1) island partition: n islands, each a count then that many members. + const islands = try readU32(b, p); + p += 4; + var i: u32 = 0; + while (i < islands) : (i += 1) { + const members = try readU32(b, p); + p += 4 + 4 * @as(usize, members); + } + const end_islands = p; + + // (2) sleep state: one u32 per mobile body. + const mobile = try readU32(b, p); + p += 4 + 4 * @as(usize, mobile); + const end_sleep = p; + + // (3) manifold cardinality: (pair_key u64, subshape_id u32, count u32). + const constraints = try readU32(b, p); + p += 4 + 16 * @as(usize, constraints); + const end_manifolds = p; + + // (4) retained pairs: one u64 key each. + const retained = try readU32(b, p); + p += 4 + 8 * @as(usize, retained); + const end_retained = p; + + if (p > b.len) return error.TruncatedWitness; + return .{ .ends = .{ end_islands, end_sleep, end_manifolds, end_retained } }; +} + +/// The first frame at which `actual` leaves the committed chain, or null. +/// +/// Compared link by link, which is the entire reason the chain is CHAINED rather +/// than a digest of the concatenation: a single digest answers "they differ" and +/// a chain answers "they differ from frame 412", which is where a bisect starts. +pub fn firstChainMismatch(actual: []const u8, expected: []const u8, digest_len: usize) ?Mismatch { + const n = @min(actual.len, expected.len) / digest_len; + var f: u32 = 0; + while (f < n) : (f += 1) { + const a = actual[f * digest_len ..][0..digest_len]; + const e = expected[f * digest_len ..][0..digest_len]; + if (!std.mem.eql(u8, a, e)) return .{ .frame = f }; + } + if (actual.len != expected.len) return .{ .frame = @intCast(n) }; + return null; +} + +/// The first (frame, trace) at which `actual` leaves the committed traces. +/// +/// A length disagreement is reported at the frame where the shorter side ends, +/// and not as a bare "sizes differ": a witness that stops early is a run that +/// stopped early, and the frame index is what says where. +pub fn firstDiscreteMismatch(actual: []const u8, expected: []const u8, frames: u32) !?Mismatch { + var off: usize = 0; + var f: u32 = 0; + while (f < frames) : (f += 1) { + if (off >= actual.len or off >= expected.len) return Mismatch{ .frame = f }; + const a = try frameSpans(actual, off); + const e = try frameSpans(expected, off); + var start = off; + for (0..4) |t| { + const ae = a.ends[t]; + const ee = e.ends[t]; + if (ae != ee or !std.mem.eql(u8, actual[start..ae], expected[start..ee])) { + return Mismatch{ .frame = f, .trace = @enumFromInt(t) }; + } + start = ae; + } + off = a.ends[3]; + } + return null; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const run = @import("run.zig"); +const trace = @import("trace.zig"); + +test "continuous chain matches committed witness" { + if (!chain_applies or !chain_witness_exists) return error.SkipZigTest; + const gpa = testing.allocator; + var a = try run.runCanonical(gpa, run.chain_frames); + defer a.deinit(gpa); + + const w = chain.?; + if (firstChainMismatch(a.chain.items, w, trace.digest_len)) |m| { + std.debug.print( + "continuous chain leaves the committed witness at frame {d} " ++ + "({s}/{s}); the witness was produced on x86_64 by the CI cell named in " ++ + "witnesses/PROVENANCE.txt\n", + .{ m.frame, precision_tag, mode_tag }, + ); + return error.ChainMismatch; + } +} + +test "four discrete traces match committed witness" { + // NO ISA GUARD, and that is the claim: the four traces are derived from + // integers, so this test asserts they are identical on every cell of the + // matrix AND on this machine, whose ISA is in neither. It is the one part of + // the witness set an AArch64 host can hold the x86_64 witness to. + const gpa = testing.allocator; + var a = try run.runCanonical(gpa, run.chain_frames); + defer a.deinit(gpa); + + if (try firstDiscreteMismatch(a.discrete.items, discrete, run.window_frames)) |m| { + std.debug.print( + "discrete trace '{s}' leaves the committed witness at frame {d} ({s})\n", + .{ if (m.trace) |t| t.label() else "length", m.frame, precision_tag }, + ); + return error.DiscreteMismatch; + } +} + +test "divergence frame is reproducible" { + // Measured against the COMMITTED x86_64 window, which is what makes the number + // meaningful: computed against this run's own poses it is a self-comparison + // and answers `none` by construction. Two runs on one machine must agree — + // the property C1.1 requires of the measurement before its value can be a + // characterisation of anything. + const gpa = testing.allocator; + var a = try run.runCanonical(gpa, run.window_frames); + defer a.deinit(gpa); + const first = try run.divergenceFrame(gpa, window, a.pose_stride); + const second = try run.divergenceFrame(gpa, window, a.pose_stride); + try testing.expectEqual(first, second); +} + +test "a corrupted chain witness is located, not merely rejected" { + // The counter-factual for `firstChainMismatch`, on the OBJECT: a witness with + // one flipped byte at a known frame must be reported AT that frame. Without + // it the comparison could return frame 0 always and every test above would + // still pass. + if (!chain_witness_exists) return error.SkipZigTest; + const gpa = testing.allocator; + const w = chain orelse return error.SkipZigTest; + const copy = try gpa.dupe(u8, w); + defer gpa.free(copy); + + const target_frame: u32 = 412; + copy[target_frame * trace.digest_len] ^= 0xFF; + + const m = firstChainMismatch(w, copy, trace.digest_len) orelse return error.ShouldHaveDiffered; + try testing.expectEqual(target_frame, m.frame); + + // And the intact pair must agree, or the assertion above would hold for a + // comparison that reports a mismatch unconditionally. + try testing.expectEqual(@as(?Mismatch, null), firstChainMismatch(w, w, trace.digest_len)); +} + +test "a corrupted discrete witness names its frame AND its trace" { + // The same counter-factual for the four-way comparison, and it has to name + // the trace: collapsing the four into one verdict would pass this test if it + // only checked the frame. + const gpa = testing.allocator; + const copy = try gpa.dupe(u8, discrete); + defer gpa.free(copy); + + // Frame 7's THIRD trace — manifold cardinality. Its span is derived by the + // same parser under test, so the probe is placed by structure and not by a + // byte offset guessed from a hex dump. + var off: usize = 0; + var f: u32 = 0; + while (f < 7) : (f += 1) off = (try frameSpans(discrete, off)).ends[3]; + const spans = try frameSpans(discrete, off); + const manifold_start = spans.ends[1]; + try testing.expect(spans.ends[2] > manifold_start); + copy[manifold_start] ^= 0xFF; + + const m = (try firstDiscreteMismatch(copy, discrete, run.window_frames)) orelse + return error.ShouldHaveDiffered; + try testing.expectEqual(@as(u32, 7), m.frame); + try testing.expectEqual(Trace.manifold_cardinality, m.trace.?); + + try testing.expectEqual( + @as(?Mismatch, null), + try firstDiscreteMismatch(discrete, discrete, run.window_frames), + ); +} + +test "the frame parser walks length prefixes and lands on the file's end" { + // NON-VACUITY for the parser itself: sixty frames parsed in sequence must + // consume the committed file EXACTLY. A parser that mis-sized any section + // would drift and end somewhere else, and every mismatch it reported after + // that point would name the wrong frame. + var off: usize = 0; + var f: u32 = 0; + while (f < run.window_frames) : (f += 1) off = (try frameSpans(discrete, off)).ends[3]; + try testing.expectEqual(discrete.len, off); +} diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/PROVENANCE.txt b/src/modules/forge/forge_3d/tests/determinism/witnesses/PROVENANCE.txt new file mode 100644 index 00000000..4f0e17e8 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/determinism/witnesses/PROVENANCE.txt @@ -0,0 +1,28 @@ +reason=nine-element scenario, second review round — three independent sensor-set discriminations and the group collision followed by island membership +cell=ubuntu-24.04 +cpu=baseline +zig_requested=0.16.0 +zig_version_output=0.16.0 +reference_window_mode=ReleaseSafe +discrete_mode=ReleaseSafe +cross_mode_agreement=true +sha=47dfbecdc46950610f78cc3817acd14b59a3ef29 +run=https://github.com/weldengine/weld/actions/runs/32132088988 +--- +witness=continuous-chain-f32-Debug.bin mode=Debug zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=continuous-chain-f32-ReleaseSafe.bin mode=ReleaseSafe zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=continuous-chain-f64-Debug.bin mode=Debug zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=continuous-chain-f64-ReleaseSafe.bin mode=ReleaseSafe zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=discrete-f32.bin mode=ReleaseSafe zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=discrete-f64.bin mode=ReleaseSafe zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=reference-window-f32.bin mode=ReleaseSafe zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 +witness=reference-window-f64.bin mode=ReleaseSafe zig=0.16.0 cpu=baseline run=https://github.com/weldengine/weld/actions/runs/32132088988 + +--- dependency, kept explicit because it is not unconditional --- +This set was ALSO produced byte-identically by local generation on aarch64-macos, +8 of 8, and that shortcut is what makes a trace-moving regeneration one push +instead of three. It is valid ONLY while inter-ISA bit-identity holds, and that +property rests on the ABSENCE of a transcendental on the deterministic path. The +day a shape introduces one, the chains part by ISA, the shortcut ends, and a cell +reddens naming the frame — the intended failure direction. The authoritative +origin above is the cell, never the laptop. diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/SHA256SUMS.txt b/src/modules/forge/forge_3d/tests/determinism/witnesses/SHA256SUMS.txt new file mode 100644 index 00000000..126ec353 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/determinism/witnesses/SHA256SUMS.txt @@ -0,0 +1,8 @@ +9ac8eadf8bd7bfb6be1d8ad4163e40a1e6669333758c7cf44eab4597130f1131 witnesses/continuous-chain-f32-Debug.bin +9ac8eadf8bd7bfb6be1d8ad4163e40a1e6669333758c7cf44eab4597130f1131 witnesses/continuous-chain-f32-ReleaseSafe.bin +28a40c97e357a65b7ddd78258f47a362aff3d449b0064df2714f015a6e83e115 witnesses/continuous-chain-f64-Debug.bin +28a40c97e357a65b7ddd78258f47a362aff3d449b0064df2714f015a6e83e115 witnesses/continuous-chain-f64-ReleaseSafe.bin +71260031520e2bf24f277eb2b8082b291c3341c37436d6d45a64184594799144 witnesses/discrete-f32.bin +d48961e3d3f4d464d332b514a0a59061ba7dfbfa0005cee1c96b8d53cbb86a53 witnesses/discrete-f64.bin +7ce18234a7f56d8a1c8049d81be2e6283aff1840a59e06533e0c4cd67b14ae53 witnesses/reference-window-f32.bin +0c14d8d2214193078cdcf924cea884ce8a9ad741b6ee890e426a0cb21aa084c3 witnesses/reference-window-f64.bin diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f32-Debug.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f32-Debug.bin new file mode 100644 index 00000000..e04795ff Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f32-Debug.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f32-ReleaseSafe.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f32-ReleaseSafe.bin new file mode 100644 index 00000000..e04795ff Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f32-ReleaseSafe.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f64-Debug.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f64-Debug.bin new file mode 100644 index 00000000..94cddfbf Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f64-Debug.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f64-ReleaseSafe.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f64-ReleaseSafe.bin new file mode 100644 index 00000000..94cddfbf Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/continuous-chain-f64-ReleaseSafe.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/discrete-f32.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/discrete-f32.bin new file mode 100644 index 00000000..fe72a354 Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/discrete-f32.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/discrete-f64.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/discrete-f64.bin new file mode 100644 index 00000000..9376523b Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/discrete-f64.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/reference-window-f32.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/reference-window-f32.bin new file mode 100644 index 00000000..e3554b00 Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/reference-window-f32.bin differ diff --git a/src/modules/forge/forge_3d/tests/determinism/witnesses/reference-window-f64.bin b/src/modules/forge/forge_3d/tests/determinism/witnesses/reference-window-f64.bin new file mode 100644 index 00000000..3cfc0e19 Binary files /dev/null and b/src/modules/forge/forge_3d/tests/determinism/witnesses/reference-window-f64.bin differ diff --git a/src/modules/forge/forge_3d/tests/mesh_test.zig b/src/modules/forge/forge_3d/tests/mesh_test.zig index 214f675d..3d28dd2e 100644 --- a/src/modules/forge/forge_3d/tests/mesh_test.zig +++ b/src/modules/forge/forge_3d/tests/mesh_test.zig @@ -820,7 +820,7 @@ test "traversal agrees with brute force" { rand.float(Real) * 2 - 1, rand.float(Real) * 2 - 1, ); - const offset = if (@reduce(.Max, @abs(away.data)) == 0) Vec3r.unit_x else away.scale(1 / away.length()); + const offset = if (away.maxAbsComponent() == 0) Vec3r.unit_x else away.scale(1 / away.length()); const origin = target.add(offset.scale(30)); const direction = offset.neg(); const max_distance: Real = 60; @@ -1270,7 +1270,7 @@ test "the ray bound prunes and still finds the nearest triangle" { rand.float(Real) * 2 - 1, rand.float(Real) * 2 - 1, ); - const offset = if (@reduce(.Max, @abs(away.data)) == 0) Vec3r.unit_x else away.scale(1 / away.length()); + const offset = if (away.maxAbsComponent() == 0) Vec3r.unit_x else away.scale(1 / away.length()); const origin = target.add(offset.scale(30)); const ray = broadphase_mod.Ray(Real).init(origin, offset.neg()); const max_distance: Real = 60; @@ -1596,7 +1596,7 @@ test "a back-face hit returns a flipped normal" { rand.float(Real) * 2 - 1, rand.float(Real) * 2 - 1, ); - if (@reduce(.Max, @abs(raw.data)) == 0) continue; + if (raw.maxAbsComponent() == 0) continue; const direction = raw.scale(1 / raw.length()); const origin = vr(4, 0, 0).sub(direction.scale(20)); inline for (.{ api.BackFaceMode.ignore, api.BackFaceMode.collide }) |mode| { @@ -1770,7 +1770,7 @@ test "the ray family agrees exactly with brute force over the mesh" { rand.float(Real) * 2 - 1, rand.float(Real) * 2 - 1, ); - if (@reduce(.Max, @abs(away.data)) == 0) continue; + if (away.maxAbsComponent() == 0) continue; const offset = away.scale(1 / away.length()); const origin = target.add(offset.scale(30)); // The direction the ENTRY will use, and the query is given the RAW one so the @@ -2353,7 +2353,7 @@ test "the five remaining entries agree exactly with brute force over the mesh" { rand.float(Real) * 2 - 1, rand.float(Real) * 2 - 1, ); - if (@reduce(.Max, @abs(away.data)) == 0) continue; + if (away.maxAbsComponent() == 0) continue; const offset = away.scale(1 / away.length()); const origin = target.add(offset.scale(25)); const raw_direction = offset.neg(); @@ -4575,3 +4575,67 @@ test "F4 the constraint order is a total key, not the sort's tie-handling" { try testing.expect(prev.subshape_id < c.subshape_id); } } + +test "the frictionless-slider residual is rounding, not energy injection" { + // M1.1.14 — the qualification the brief owes on the M1.1.13.1 residual, and it + // is the FIRST branch of the alternative it imposed: the residual PERSISTS. + // Pinning the float environment did not move it — `5.000002` at f32, the same + // figure M1.1.13.1 recorded — so its cause was NOT the unpinned environment, + // which was this milestone's own subject. + // + // Measured after the libm removal, the explicit folds, the installed FPU and + // `-Dcpu=baseline`, counted in ULP AT THE VALUE 5.0: + // + // f32 retained 5.000002 bits 0x40A00004 4 ULP + // f64 retained 5.0 bits 0x4014000000000003 3 ULP + // + // THE DISCRIMINANT, IN EXACT FORM RATHER THAN IN DECIMALS. Energy injected at a + // PHYSICAL rate is precision-independent in relative terms, so reproducing the + // f32 excess at f64 means reproducing the same ABSOLUTE excess at the same + // value. 5.0 lies in [4, 8), so its exponent is 2 and + // + // ULP_f32(5.0) = 2^(2-23) = 2^-21 + // ULP_f64(5.0) = 2^(2-52) = 2^-50 + // + // whose ratio is 2^29. The f32 excess is therefore + // + // 4 ULP_f32 = 4 x 2^29 ULP_f64 = 2^31 ULP_f64 + // + // against THREE measured — nine orders of margin. The form is stated as powers + // of two on purpose: it is checkable by exponent arithmetic, by any reader, + // without a machine. A ten-digit decimal in a permanent pin is unverifiable, + // and that is exactly how the first version of this number survived review. + // + // THAT FIRST VERSION READ 1 717 988 150 AND WAS WRONG BY 5/4, because it was + // `relative excess / eps`, which counts ULP AT 1.0 while the question is about + // ULP AT 5.0. A quantity computed on one base and reported on another — the + // same shape as collected-versus-source, local-versus-cell, and + // `live_tests`-versus-collected-total earlier in this milestone. + // + // THE f64 RESIDUAL'S CAUSE IS NOT ATTRIBUTED, and deliberately so. M1.1.12 + // measured exactly 5.0 there; three ULP appear today. Two changes sit between + // those measurements — the TGS Soft port at M1.1.13.1, and this milestone's + // explicit left folds, which alter the summation order of `dot` and `lengthSq` + // and are of exactly this magnitude. Neither has been measured against this + // scene, so neither is named as the cause. The conclusion does not depend on + // it. + // + // The bound below is in ULP rather than in metres for the same reason the + // discriminant is: a metre bound would pass at f64 for an energy injection nine + // orders too large, and would be an assertion that cannot fail where it matters. + const gpa = testing.allocator; + const retained = try slideSphere(gpa, true, 0, cos_5_deg, 0.45, 60); + + const Bits = std.meta.Int(.unsigned, @bitSizeOf(Real)); + const five: Real = 5.0; + const got: Bits = @bitCast(retained); + const want: Bits = @bitCast(five); + + // POSITIVE WITNESS FIRST: there IS an excess. Without it the bound below is + // satisfied by a slider that lost speed, and the test would pin nothing. + try testing.expect(retained > five); + + // And it is a handful of ULP. `8` is loose against the 4 and 3 measured and + // tight against the 2^31 an energy injection needs at f64. + try testing.expect(got - want <= 8); +} diff --git a/src/modules/forge/forge_3d/tests/solver_test.zig b/src/modules/forge/forge_3d/tests/solver_test.zig index dfdfba03..6de6e95f 100644 --- a/src/modules/forge/forge_3d/tests/solver_test.zig +++ b/src/modules/forge/forge_3d/tests/solver_test.zig @@ -59,6 +59,8 @@ const integration = @import("../pipeline/integration.zig"); const sleep = @import("../pipeline/sleep.zig"); const sensor = @import("../pipeline/sensor.zig"); const rigid = @import("../rigid/root.zig"); +// M1.1.14 — the module's float-environment check, asserted where a world opens. +const determinism = @import("../determinism.zig"); const api = @import("weld_forge"); const foundation = @import("foundation"); @@ -138,6 +140,14 @@ pub const World = struct { /// A world with the given gravity and fixed timestep. Default `SolverConfig`, /// sleeping ENABLED. pub fn init(gravity: Vec3r, dt: Real) World { + // M1.1.14 — THE physics entry point, until `PhysicsWorld` exists at + // M1.1.15 and inherits this call. Opening a world on a thread whose + // float environment is not the engine's makes every number this world + // produces incomparable with the same world opened elsewhere, so the + // state is checked once, here, where a world begins — and ASSERTED, not + // installed (`ARCH-031` rule 5; the reason the two verbs differ is in + // `../determinism.zig`). + determinism.assertFloatEnvironment(); return .{ .bp = Bp.init(.{}), .gravity = gravity, .dt = dt }; } @@ -214,14 +224,78 @@ pub const World = struct { self.bm.removeBody(id); } + /// The proxy of `id`, or `null` once the body has been removed. + fn proxyOf(self: *const World, id: BodyId) ?Bp.Proxy { + for (self.bodies.items) |b| { + if (b.id == id) return b.proxy; + } + return null; + } + + /// Whether a retained pair still satisfies §1.7 step 2 — "removal on FAT-AABB + /// separation only". + /// + /// Three cases, exhaustive on what a proxy can be, and the middle one is why + /// this is not a two-box test. A half-space has no box at all (§1.11.15), so a + /// pair with one on either side is tested by the SAME exact predicate the + /// traversal uses, `Aabb.overlapsHalfSpace` from `foundation/math` — never a + /// second copy of that formula. Two half-spaces both force static bodies and + /// can never separate, so such a pair is retained unconditionally. + /// + /// The FAT boxes are the ones compared, deliberately. Comparing the tight boxes + /// would purge on a transient sub-margin separation and lose the contact until + /// the body sank back past the margin — the defect `test "small hop within the + /// fat margin keeps the contact pair alive"` was written for at M1.1.6. The + /// margin exists precisely so that this test has hysteresis. + fn pairStillOverlaps(self: *const World, a: BodyId, b: BodyId) bool { + // A removed body's pair serves nothing: W4 has already woken whoever was + // retained with it, at `removeBody`, and there is no proxy left to test. + const pa = self.proxyOf(a) orelse return false; + const pb = self.proxyOf(b) orelse return false; + + const box_a = self.bp.proxyAabb(pa); + const box_b = self.bp.proxyAabb(pb); + if (box_a) |ba| { + if (box_b) |bb| return ba.overlaps(bb); + const hs = self.bp.unboundedShape(pb) orelse return false; + return ba.overlapsHalfSpace(hs.normal, hs.distance); + } + if (box_b) |bb| { + const hs = self.bp.unboundedShape(pa) orelse return false; + return bb.overlapsHalfSpace(hs.normal, hs.distance); + } + return true; // two half-spaces: both static, no separation is possible + } + /// Advance one fixed tick through the normative cycle (file header). pub fn step(self: *World, gpa: std.mem.Allocator) !void { - // (1) broadphase candidate deltas → (2) persistent active set. Never pruned: - // every emitted pair is retained, which is a CORRECTNESS condition of sleep - // (§1.8.7) and not just warm-start persistence. + // (1) broadphase candidate deltas → (2) persistent active set. + // + // The set is PERSISTENT and its retention is a CORRECTNESS condition of + // sleep (§1.8.7), not merely warm-start persistence — a sleeper emits + // nothing in broadphase, so these retained pairs ARE the wake graph. + // + // M1.1.14 — it is also PRUNED, on the one condition §1.7 step 2 allows: + // the two FAT AABBs have separated. Until this milestone the harness kept + // every pair it had ever seen, a conservative superset of the normative + // rule; that is sound for the wake graph but makes the retained set a + // monotonically growing sequence, and a determinism trace over a set that + // can only grow passes by ACCUMULATION and proves nothing. The + // non-vacuity probe on this set is what turns it back into an oracle. try self.bp.computePairs(gpa, &self.scratch); for (self.scratch.items) |p| try self.active.append(gpa, (@as(u64, p.a) << 32) | p.b); sortDedup(&self.active); + { + var w: usize = 0; + for (self.active.items) |key| { + const a: BodyId = @intCast(key >> 32); + const b: BodyId = @intCast(key & 0xFFFF_FFFF); + if (!self.pairStillOverlaps(a, b)) continue; + self.active.items[w] = key; + w += 1; + } + self.active.shrinkRetainingCapacity(w); + } // (3) external forces — read-only, no code. See the file header. @@ -616,6 +690,41 @@ test "small hop within the fat margin keeps the contact pair alive" { try testing.expect(min_y >= y_rest - 0.02); } +test "separation beyond the fat margin prunes the retained pair" { + // THE COMPLEMENT of the small-hop test above, and the pair is the point: one + // asserts the set RETAINS inside the margin, this one asserts it PRUNES outside + // it. Either alone is satisfiable by a degenerate rule — never prune, or always + // prune — and only the two together pin §1.7 step 2, "removal on fat-AABB + // separation only". + // + // It is also the NON-VACUITY probe M1.1.14 owes its fourth discrete trace. A + // retained set that can only grow is a monotone sequence, and a trace over a + // monotone sequence agrees with itself by accumulation whatever the engine did. + // Until this milestone the harness never pruned, so that trace was about to be + // an oracle that could not fail. + const gpa = testing.allocator; + var world = World.initNoSleep(Vec3r.zero, fixed_dt); // no gravity: a clean departure + defer world.deinit(gpa); + const box = try groundAndBox(gpa, &world, 1.0, 0); // rests flush on the ground + + var t: u32 = 0; + while (t < 4) : (t += 1) try world.step(gpa); + const retained_in_contact = world.active.items.len; + // POSITIVE WITNESS: the pair exists before it can be shown to disappear. + // Without this, "the set shrank" is satisfied by a set that was empty all along. + try testing.expect(retained_in_contact >= 1); + + // Leave, decisively. The fat margin is 0.1 m, so a departure of several metres + // is far outside any hysteresis and separates the two fat boxes. + world.bm.addImpulse(box, vr(0, 40, 0)); + t = 0; + while (t < 60) : (t += 1) try world.step(gpa); + + try testing.expect(world.bm.position(box).?.toArray()[1] > 5); + try testing.expect(world.active.items.len < retained_in_contact); + try testing.expectEqual(@as(usize, 0), world.active.items.len); +} + test "solve is deterministic across identical runs" { const gpa = testing.allocator; diff --git a/src/modules/render/render_graph/passes/capture.zig b/src/modules/render/render_graph/passes/capture.zig index 51f93c49..48fd239a 100644 --- a/src/modules/render/render_graph/passes/capture.zig +++ b/src/modules/render/render_graph/passes/capture.zig @@ -28,25 +28,40 @@ pub const Config = struct { /// Image dimensions (for the blit + the PPM header). width: u32, height: u32, + /// Storage the returned `Pass.reads`/`Pass.writes` point at. + /// + /// M1.1.14 — before this field, `buildPass` returned slices of an ANONYMOUS + /// LITERAL built in its own stack frame, so the `Pass` carried a dangling + /// pointer the moment it returned. Measured: `writes.ptr` was a stack address, + /// the access mask read `false` immediately after the call, and a fresh call at + /// the SAME address read `true`. Live since M0.4 and invisible because nothing + /// compiled this file's tests until the M1.1.14 dead-test sweep. + /// + /// The config owns it and introduces NO new lifetime constraint: the config is + /// already the pass's `ctx`, so it had to outlive the pass by construction. + read_storage: [1]pass_mod.ResourceUsage = undefined, + write_storage: [1]pass_mod.ResourceUsage = undefined, }; /// Builds a capture Pass ready to be added to a Graph. -pub fn buildPass(config: *const Config) pass_mod.Pass { +pub fn buildPass(config: *Config) pass_mod.Pass { + config.read_storage[0] = .{ + .resource = .{ .texture = config.color_source }, + .stage = .{ .fragment = true }, + .access = .{ .read = true }, + .layout = .transfer_src, + }; + config.write_storage[0] = .{ + .resource = .{ .buffer = config.capture_buffer }, + .stage = .{ .fragment = true }, + .access = .{ .write = true }, + .layout = null, + }; return .{ .name = "capture_to_buffer", .barrier_mode = .auto, - .reads = &.{.{ - .resource = .{ .texture = config.color_source }, - .stage = .{ .fragment = true }, - .access = .{ .read = true }, - .layout = .transfer_src, - }}, - .writes = &.{.{ - .resource = .{ .buffer = config.capture_buffer }, - .stage = .{ .fragment = true }, - .access = .{ .write = true }, - .layout = null, - }}, + .reads = config.read_storage[0..], + .writes = config.write_storage[0..], .body = body, .ctx = @as(*anyopaque, @ptrCast(@constCast(config))), }; @@ -64,7 +79,7 @@ test "capture: buildPass declares texture read + buffer write" { const t = std.testing; const tex = gal.types.TextureHandle{ .inner = 20 }; const buf = gal.types.BufferHandle{ .inner = 21 }; - const cfg: Config = .{ + var cfg: Config = .{ .color_source = tex, .capture_buffer = buf, .width = 1280, diff --git a/src/modules/render/render_graph/passes/depth_prepass.zig b/src/modules/render/render_graph/passes/depth_prepass.zig index 2d3b3a92..b73f997a 100644 --- a/src/modules/render/render_graph/passes/depth_prepass.zig +++ b/src/modules/render/render_graph/passes/depth_prepass.zig @@ -18,20 +18,33 @@ pub const Config = struct { depth_target: gal.types.TextureHandle, /// Depth clear value (1.0 by default — reverse-Z = 0.0 Phase 1+). depth_clear: f32 = 1.0, + /// Storage the returned `Pass.reads`/`Pass.writes` point at. + /// + /// M1.1.14 — before this field, `buildPass` returned slices of an ANONYMOUS + /// LITERAL built in its own stack frame, so the `Pass` carried a dangling + /// pointer the moment it returned. Measured: `writes.ptr` was a stack address, + /// the access mask read `false` immediately after the call, and a fresh call at + /// the SAME address read `true`. Live since M0.4 and invisible because nothing + /// compiled this file's tests until the M1.1.14 dead-test sweep. + /// + /// The config owns it and introduces NO new lifetime constraint: the config is + /// already the pass's `ctx`, so it had to outlive the pass by construction. + write_storage: [1]pass_mod.ResourceUsage = undefined, }; /// Builds a depth-prepass Pass ready to be added to a Graph. -pub fn buildPass(config: *const Config) pass_mod.Pass { +pub fn buildPass(config: *Config) pass_mod.Pass { + config.write_storage[0] = .{ + .resource = .{ .texture = config.depth_target }, + .stage = .{ .vertex = true, .fragment = true }, + .access = .{ .write = true, .depth_attachment = true }, + .layout = .depth_stencil_attachment, + }; return .{ .name = "depth_prepass", .barrier_mode = .auto, .reads = &.{}, - .writes = &.{.{ - .resource = .{ .texture = config.depth_target }, - .stage = .{ .vertex = true, .fragment = true }, - .access = .{ .write = true, .depth_attachment = true }, - .layout = .depth_stencil_attachment, - }}, + .writes = config.write_storage[0..], .body = body, .ctx = @as(*anyopaque, @ptrCast(@constCast(config))), }; @@ -51,10 +64,12 @@ fn body(encoder: ?*anyopaque, ctx: ?*anyopaque) anyerror!void { test "depth_prepass: buildPass populates writes" { const t = std.testing; const tex = gal.types.TextureHandle{ .inner = 1 }; - const cfg: Config = .{ .depth_target = tex }; + var cfg: Config = .{ .depth_target = tex }; const p = buildPass(&cfg); try t.expectEqual(@as(usize, 0), p.reads.len); try t.expectEqual(@as(usize, 1), p.writes.len); try t.expectEqualStrings("depth_prepass", p.name); try t.expect(p.writes[0].access.depth_attachment); + // The slice must point INTO the config, never at a returned frame. + try t.expectEqual(@as([*]const pass_mod.ResourceUsage, &cfg.write_storage), p.writes.ptr); } diff --git a/src/modules/render/render_graph/passes/forward.zig b/src/modules/render/render_graph/passes/forward.zig index a3ea7c75..3d1ee3ed 100644 --- a/src/modules/render/render_graph/passes/forward.zig +++ b/src/modules/render/render_graph/passes/forward.zig @@ -22,26 +22,41 @@ pub const Config = struct { depth_target: gal.types.TextureHandle, /// Clear color of the color attachment. clear_color: gal.types.ColorClear = .{ .r = 0.05, .g = 0.05, .b = 0.08, .a = 1.0 }, + /// Storage the returned `Pass.reads`/`Pass.writes` point at. + /// + /// M1.1.14 — before this field, `buildPass` returned slices of an ANONYMOUS + /// LITERAL built in its own stack frame, so the `Pass` carried a dangling + /// pointer the moment it returned. Measured: `writes.ptr` was a stack address, + /// the access mask read `false` immediately after the call, and a fresh call at + /// the SAME address read `true`. Live since M0.4 and invisible because nothing + /// compiled this file's tests until the M1.1.14 dead-test sweep. + /// + /// The config owns it and introduces NO new lifetime constraint: the config is + /// already the pass's `ctx`, so it had to outlive the pass by construction. + read_storage: [1]pass_mod.ResourceUsage = undefined, + write_storage: [1]pass_mod.ResourceUsage = undefined, }; /// Builds a forward opaque Pass ready to be added to a Graph. -pub fn buildPass(config: *const Config) pass_mod.Pass { +pub fn buildPass(config: *Config) pass_mod.Pass { + // The depth is read-only (depth test, not write). + config.read_storage[0] = .{ + .resource = .{ .texture = config.depth_target }, + .stage = .{ .fragment = true }, + .access = .{ .read = true }, + .layout = .depth_stencil_attachment, + }; + config.write_storage[0] = .{ + .resource = .{ .texture = config.color_target }, + .stage = .{ .fragment = true }, + .access = .{ .write = true, .color_attachment = true }, + .layout = .color_attachment, + }; return .{ .name = "forward_opaque", .barrier_mode = .auto, - .reads = &.{.{ - // The depth is read-only (depth test, not write). - .resource = .{ .texture = config.depth_target }, - .stage = .{ .fragment = true }, - .access = .{ .read = true }, - .layout = .depth_stencil_attachment, - }}, - .writes = &.{.{ - .resource = .{ .texture = config.color_target }, - .stage = .{ .fragment = true }, - .access = .{ .write = true, .color_attachment = true }, - .layout = .color_attachment, - }}, + .reads = config.read_storage[0..], + .writes = config.write_storage[0..], .body = body, .ctx = @as(*anyopaque, @ptrCast(@constCast(config))), }; @@ -59,10 +74,13 @@ test "forward: buildPass declares depth read + color write" { const t = std.testing; const color = gal.types.TextureHandle{ .inner = 10 }; const depth = gal.types.TextureHandle{ .inner = 11 }; - const cfg: Config = .{ .color_target = color, .depth_target = depth }; + var cfg: Config = .{ .color_target = color, .depth_target = depth }; const p = buildPass(&cfg); try t.expectEqual(@as(usize, 1), p.reads.len); try t.expectEqual(@as(usize, 1), p.writes.len); try t.expectEqualStrings("forward_opaque", p.name); try t.expect(p.writes[0].access.color_attachment); + // Both slices must point INTO the config, never at a returned frame. + try t.expectEqual(@as([*]const pass_mod.ResourceUsage, &cfg.read_storage), p.reads.ptr); + try t.expectEqual(@as([*]const pass_mod.ResourceUsage, &cfg.write_storage), p.writes.ptr); } diff --git a/src/modules/render/shader_pipeline/hot_reload.zig b/src/modules/render/shader_pipeline/hot_reload.zig index 87708a53..48b29055 100644 --- a/src/modules/render/shader_pipeline/hot_reload.zig +++ b/src/modules/render/shader_pipeline/hot_reload.zig @@ -13,6 +13,7 @@ //! behavior + §Notes decision 7). const std = @import("std"); +const foundation = @import("foundation"); const compiler = @import("compiler.zig"); const cache = @import("cache.zig"); @@ -78,6 +79,8 @@ pub fn init(allocator: std.mem.Allocator, config: Config) Watcher { /// Thread body — poll → diff → recompile loop. fn threadMain(watcher: *Watcher) void { + // `ARCH-031` rule 5 — a thread-creation site installs the float environment. + foundation.math.float_env.install(); // i96 mirrors `std.Io.Timestamp.nanoseconds` width so the map value // round-trips losslessly. Zig 0.16's `Stat.mtime` shifted from i128 // to a `Io.Timestamp` struct; we store the inner nanoseconds. diff --git a/src/runtime/main.zig b/src/runtime/main.zig index c149f53e..39cb6bd9 100644 --- a/src/runtime/main.zig +++ b/src/runtime/main.zig @@ -26,6 +26,9 @@ const std = @import("std"); const builtin = @import("builtin"); const weld_core = @import("weld_core"); +// M1.1.14 — the engine float environment (`ARCH-031` rule 5): the main thread +// is not born of a spawn, so it is installed here rather than by the job system. +const foundation = @import("foundation"); const ipc = weld_core.ipc; const framing = ipc.framing; const messages = ipc.messages; @@ -101,6 +104,12 @@ fn sleepMs(ms: u64) void { } pub fn main(init: std.process.Init.Minimal) !void { + // M1.1.14 — the main thread is not born of a spawn, so it does not pass + // through the job system's worker entry and receives the engine float + // environment here instead (`ARCH-031` rule 5, `engine-platform.md` §4). + // First statement, before anything can compute. + foundation.math.float_env.install(); + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const gpa = arena.allocator(); @@ -216,6 +225,12 @@ const ReaderState = struct { }; fn readerLoop(state: *ReaderState) void { + // `ARCH-031` rule 5 — a THREAD-CREATION site's body installs the float + // environment. `main` installing it does not cover this thread: the state is + // per-thread, and a reader that inherits the OS default is a second + // arithmetic in the same process. + foundation.math.float_env.install(); + // Sized to the largest frame the editor can send the runtime — // computed over the FULL incoming set (every editor→runtime type the // reader reads, whether or not it decodes it: `recvFrame` buffers the diff --git a/tests/lint/bad/float_reduce/reduce_add.zig b/tests/lint/bad/float_reduce/reduce_add.zig new file mode 100644 index 00000000..562d770b --- /dev/null +++ b/tests/lint/bad/float_reduce/reduce_add.zig @@ -0,0 +1,7 @@ +//! Fixture — `@reduce(.Add, …)` on a float path. Rule `no_float_reduce` must fire: +//! the summation order is the backend's, not the source's. + +/// Sum of a 3-lane float vector, the way `ARCH-031` rule 3 forbids. +pub fn sum(v: @Vector(3, f32)) f32 { + return @reduce(.Add, v); +} diff --git a/tests/lint/bad/float_reduce/reduce_max.zig b/tests/lint/bad/float_reduce/reduce_max.zig new file mode 100644 index 00000000..6d43558c --- /dev/null +++ b/tests/lint/bad/float_reduce/reduce_max.zig @@ -0,0 +1,7 @@ +//! Fixture — `@reduce(.Max, …)`. Flagged for order AND for leaving NaN +//! propagation to the backend. + +/// Largest lane of a float vector. +pub fn largest(v: @Vector(4, f64)) f64 { + return @reduce(.Max, v); +} diff --git a/tests/lint/bad/float_reduce/reduce_mul_min.zig b/tests/lint/bad/float_reduce/reduce_mul_min.zig new file mode 100644 index 00000000..d5040606 --- /dev/null +++ b/tests/lint/bad/float_reduce/reduce_mul_min.zig @@ -0,0 +1,14 @@ +//! Fixture — the two remaining flagged operations, and a marker placed TWO lines +//! above its statement, which is out of reach and must not exempt anything. + +/// Product of the lanes. +pub fn product(v: @Vector(3, f32)) f32 { + return @reduce(.Mul, v); +} + +/// Smallest lane. The marker below is too far to grant the exemption. +pub fn smallest(v: @Vector(3, f32)) f32 { + // WELD_INTEGER_LANES + + return @reduce(.Min, v); +} diff --git a/tests/lint/good/float_reduce_ok.zig b/tests/lint/good/float_reduce_ok.zig new file mode 100644 index 00000000..9a3eba7b --- /dev/null +++ b/tests/lint/good/float_reduce_ok.zig @@ -0,0 +1,20 @@ +//! Fixture — the three shapes rule `no_float_reduce` must leave alone: an +//! order-free boolean reduction, an integer reduction that declares its lanes, +//! and prose naming the builtin. + +/// Whether every lane of `a` is at most the matching lane of `b`. `.And` is +/// boolean and order-free, so it is never a determinism question. +pub fn allAtMost(a: @Vector(3, f32), b: @Vector(3, f32)) bool { + return @reduce(.And, a <= b); +} + +/// Sum of integer lanes. Integer addition is associative and exact, so the +/// backend's reduction order cannot change the result — the site declares it. +pub fn integerSum(v: @Vector(4, u32)) u32 { + // WELD_INTEGER_LANES: `u32` lanes, exact under any summation order. + return @reduce(.Add, v); +} + +/// Never write `@reduce(.Add, v)` on a float path — this doc comment names the +/// builtin and must not be read as a site, the rule being written on tokens. +pub fn documented() void {} diff --git a/tests/lint/runner_test.zig b/tests/lint/runner_test.zig index ff21a64b..9728cbe0 100644 --- a/tests/lint/runner_test.zig +++ b/tests/lint/runner_test.zig @@ -140,6 +140,11 @@ test "rule no_device_dispatch_outside_gal flags raw dispatch and forbidden marke try forEachZigFile(ctx.gpa, ctx.io, "tests/lint/bad/device_dispatch", &ctx, &assertBadFixture); } +test "rule no_float_reduce flags bad fixtures" { + var ctx: Context = .{ .gpa = std.testing.allocator, .io = std.testing.io }; + try forEachZigFile(ctx.gpa, ctx.io, "tests/lint/bad/float_reduce", &ctx, &assertBadFixture); +} + test "good fixtures pass clean" { var ctx: Context = .{ .gpa = std.testing.allocator, .io = std.testing.io }; try forEachZigFile(ctx.gpa, ctx.io, "tests/lint/good", &ctx, &assertGoodFile); diff --git a/tools/asm_inventory/forge_3d_surface.zig b/tools/asm_inventory/forge_3d_surface.zig new file mode 100644 index 00000000..4fecbdb0 --- /dev/null +++ b/tools/asm_inventory/forge_3d_surface.zig @@ -0,0 +1,89 @@ +//! Inventory root for `zig build forge-asm-inventory`. +//! +//! `forge_3d/root.zig` is a re-export file: it declares types and constants and +//! almost no function bodies of its own. Zig is lazy, so compiling it directly +//! to an object emits nothing at all — measured, and caught by the scanner's +//! own non-vacuity guard, which reported `0 call sites examined` on all three +//! targets before this file existed. An empty listing that "contains no libm +//! call" is the exact shape of a harness reporting success without measuring +//! anything. +//! +//! This root forces the module's whole public surface into codegen, by taking +//! the ADDRESS of every public function reachable from `forge_3d`'s root +//! namespace, transitively through its public type declarations. Referencing a +//! function pointer defeats laziness; `export`ing the collector defeats +//! dead-code elimination. +//! +//! **Why reflection and not a hand-written list.** A list of entry points is a +//! list that drifts: the milestone that adds the next query entry, or the next +//! pipeline pass, has to remember to extend it, and forgetting produces a +//! GREEN inventory over a smaller surface — a false negative that looks exactly +//! like a pass. Reflection cannot forget. What it can do is silently cover less +//! than one thinks, which is why the scanner reports the number of call sites it +//! examined on every run and refuses a run that examined none. + +const std = @import("std"); +const forge_3d = @import("forge_3d"); + +/// Where the collected function addresses land. +/// +/// A mutable global that the exported anchor RETURNS. Both halves are needed +/// and were arrived at by measurement: the first attempt wrote `_ = &f`, which +/// forces the function to be ANALYSED but not EMITTED — the resulting listing +/// carried 37 000 lines of debug records, one emitted function (the anchor) and +/// ZERO call instructions, which the scanner refused as vacuous. Summing the +/// addresses at RUNTIME, into a global whose value escapes through a return, is +/// what makes each address load a real reference to a real symbol. +var address_sink: usize = 0; + +/// Recursively take the address of every public function in `T` and in its +/// public struct/union/enum declarations. +/// +/// `depth` bounds the walk: the namespace graph has cycles (a type re-exported +/// by two namespaces, a package root that re-exports a sibling), and without a +/// bound the walk would not terminate. Eight is far past the module's actual +/// nesting — root → package → file → type → nested type is four. +/// +/// GENERIC functions are skipped rather than instantiated: `&f` on a function +/// with a `comptime` or `anytype` parameter has no address to take, and picking +/// arguments for it here would be inventing a call the engine never makes. The +/// generic pipeline of `forge_3d` is reached anyway, through the `Real`-bound +/// aliases the root re-exports — which is the instantiation the engine actually +/// ships, and therefore the one worth inventorying. +fn referenceAll(comptime T: type, comptime depth: u8) void { + if (depth == 0) return; + const info = @typeInfo(T); + const decls = switch (info) { + .@"struct" => |s| s.decls, + .@"union" => |u| u.decls, + .@"enum" => |e| e.decls, + .@"opaque" => |o| o.decls, + else => return, + }; + inline for (decls) |decl| { + const field = @field(T, decl.name); + const FieldType = @TypeOf(field); + if (FieldType == type) { + switch (@typeInfo(field)) { + .@"struct", .@"union", .@"enum", .@"opaque" => referenceAll(field, depth - 1), + else => {}, + } + } else if (@typeInfo(FieldType) == .@"fn") { + // A generic function has no runtime address; skip it rather than + // fail to compile, and say so in the doc comment above rather than + // leave a reader to infer it from a `catch`-shaped silence. + if (!@typeInfo(FieldType).@"fn".is_generic) { + address_sink +%= @intFromPtr(&@field(T, decl.name)); + } + } + } +} + +/// The object's single exported symbol, and the reason anything is emitted at +/// all: it walks the surface at runtime, so every address it takes is a +/// reference the backend must satisfy, and it returns the accumulator so the +/// whole walk cannot be proved dead. +export fn weld_forge_3d_asm_inventory_anchor() usize { + referenceAll(forge_3d, 8); + return address_sink; +} diff --git a/tools/asm_inventory/main.zig b/tools/asm_inventory/main.zig new file mode 100644 index 00000000..16ca352d --- /dev/null +++ b/tools/asm_inventory/main.zig @@ -0,0 +1,443 @@ +//! `tools/asm_inventory` — mechanical inventory of EXTERNAL TRANSCENDENTAL +//! CALLS in emitted assembly (`ARCH-031` rule 4, conformance test 2: "the +//! inventory is read in the emitted assembly, not in the intentions of the +//! code"). +//! +//! Takes one or more `.s` files and exits non-zero if any of them contains a +//! call to a libm transcendental. Used by `zig build forge-asm-inventory`, +//! which emits the assembly of `forge_3d` for the three targets the engine +//! ships and hands the paths here. +//! +//! **Why a Zig scanner and not `grep`.** The brief for M1.1.14 requires the +//! pattern to be anchored on the instruction mnemonic at line start and warns +//! against `\b`, a GNU extension that silently matches NOTHING on BSD grep — +//! the failure mode being a check that reports clean because it never matched +//! anything at all. Substituting a scanner removes the whole class rather than +//! working around one instance of it: there is no regular expression here, no +//! shell quoting, and identical behaviour on every host. The substitution is +//! named, as the brief requires. +//! +//! **What this inventory does NOT establish, and it has to be said here because +//! the next reader will assume otherwise.** `ARCH-031` rule 2 — no implicit +//! multiply-add contraction — is exercised on ONE of the three targets, not +//! three, and the reason is the `-Dcpu=baseline` pinning the same invariant's +//! rule 6 requires. Measured, at baseline, on a two-line probe: +//! +//! - **aarch64**: `@mulAdd` emits `fmadd d0, d0, d1, d2`. `FMADD` is in the +//! ARMv8-A base ISA, so the backend COULD contract and does not — rule 2 is +//! genuinely observed there, and the zero-fused-instruction count over +//! `forge_3d`'s listing is a real measurement rather than a vacuous one. +//! - **x86_64**: `@mulAdd` emits `jmp fma@PLT`. FMA3 is Haswell and later, so +//! baseline (SSE2) has no fusion instruction at all and rule 2 holds there by +//! ABSENCE OF HARDWARE rather than by respect of a contract. Structurally in +//! the engine's favour, and worth nothing as evidence. +//! +//! Note also that `fma` is not among the thirteen names below — `ARCH-031` +//! rule 2 admits an explicit `@mulAdd` as a declared exception at its site — so +//! this inventory would NOT flag that external call. It is not meant to: rule 2 +//! is about what the backend does silently, rule 4 about what the source calls. +//! +//! **What it looks for, and why that shape.** A transcendental reaches the +//! object as a CALL to a named symbol. So the scanner reads each line, takes +//! the first token as a mnemonic, keeps only branch-with-link and tail-call +//! mnemonics, and inspects the operand. Anchoring on the mnemonic is what makes +//! the answer trustworthy: the strings `cos` and `exp` also appear in comments, +//! in section names, in mangled Zig symbols like `..cosine_test`, and in +//! `.ascii` payloads, and a substring search over whole lines would flag all of +//! them and be tuned into uselessness within a week. + +const std = @import("std"); + +/// The thirteen names `ARCH-031` rule 4 enumerates. +/// +/// The list is the invariant's, verbatim and in its order, so that a reader can +/// check the two against each other without interpreting. Suffixed and +/// decorated spellings are handled by `matchesForbidden`, not by expanding this +/// table — one name per mathematical function, one place to edit. +const forbidden = [_][]const u8{ + "sin", "cos", "tan", "asin", "acos", "atan", "atan2", + "pow", "exp", "log", "fmod", "hypot", "cbrt", +}; + +/// Mnemonics that transfer control to a named symbol. +/// +/// `jmp` / `b` are here because a TAIL CALL is a call: a leaf that ends in +/// `jmp cosf` has called `cosf` just as surely as one that ends in +/// `call cosf; ret`, and at `-OReleaseSafe` the backend emits both shapes. An +/// inventory that only knew `call` would be blind to exactly the optimised form +/// the shipped build uses. +const call_mnemonics = [_][]const u8{ + // x86_64 + "call", "callq", "jmp", "jmpq", + // AArch64 + "bl", "blr", "b", "br", +}; + +/// A finding: where, and what. +const Hit = struct { + file: []const u8, + line_no: usize, + line: []const u8, + symbol: []const u8, +}; + +/// Whether `c` can appear inside an assembler symbol name. +fn isSymbolChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_' or c == '.' or c == '$'; +} + +/// Strip the decorations a linker or an ABI puts around a symbol name, leaving +/// the bare C identifier. +/// +/// Three, each observed on a target the engine ships: a leading `__imp_` +/// (Windows import thunks), leading underscores (Mach-O prefixes exactly one, +/// a C library's internal alias may carry two), and a trailing `@PLT` / +/// `@GOTPCREL` / `@plt` relocation suffix (ELF). `__imp_` is removed FIRST, +/// because stripping underscores first would eat its own prefix and leave +/// `imp_cos`, which matches nothing. +fn bareSymbol(token: []const u8) []const u8 { + var s = token; + if (std.mem.startsWith(u8, s, "__imp_")) s = s["__imp_".len..]; + while (s.len > 0 and s[0] == '_') s = s[1..]; + if (std.mem.indexOfScalar(u8, s, '@')) |at| s = s[0..at]; + return s; +} + +/// Whether an operand names a forbidden function, in any addressing form. +/// +/// The operand is TOKENISED and every token tested, rather than the operand +/// being treated as one symbol. A direct call is `call cosf@PLT`, but the same +/// call under `-fno-plt` is `call qword ptr [rip + cosf@GOTPCREL]` and on +/// AArch64 a far call goes through `adrp`/`ldr` into `blr xN`. Reading the +/// operand as a single name serves the first shape and silently misses the +/// second — a false negative on exactly the build flags a distribution is most +/// likely to add. Tokenising costs nothing in false positives, since matching +/// is exact against thirteen names and no register, size keyword or address +/// arithmetic spells one of them. +fn operandNamesForbidden(operand: []const u8) ?[]const u8 { + var i: usize = 0; + while (i < operand.len) { + if (!isSymbolChar(operand[i])) { + i += 1; + continue; + } + const start = i; + while (i < operand.len and (isSymbolChar(operand[i]) or operand[i] == '@')) i += 1; + if (matchesForbidden(bareSymbol(operand[start..i]))) |name| return name; + } + return null; +} + +/// Whether `symbol` is one of the forbidden functions, in any of the spellings +/// a C library uses. +/// +/// A libm function `f` ships as `f` (double), `f` + `"f"` (single) and `f` + +/// `"l"` (long double). Matching is EXACT against those three spellings and +/// never by prefix: a prefix test would flag `cosine_of`, `expand`, `logger`, +/// `powerset` and every Zig symbol whose mangled name happens to begin with one +/// of the thirteen — which is how an inventory acquires a suppression list and +/// stops meaning anything. +fn matchesForbidden(symbol: []const u8) ?[]const u8 { + for (forbidden) |name| { + if (std.mem.eql(u8, symbol, name)) return name; + if (symbol.len == name.len + 1 and + std.mem.startsWith(u8, symbol, name) and + (symbol[name.len] == 'f' or symbol[name.len] == 'l')) return name; + } + return null; +} + +/// Scan one assembly listing, appending every finding to `out`. +/// +/// Returns the number of call instructions examined. The COUNT is returned and +/// reported, not discarded: an inventory that examined zero calls and found +/// zero transcendentals is indistinguishable, from its exit code alone, from +/// one that examined thousands — and the first is a broken harness reporting +/// success, which is the failure mode this whole file is shaped against. +fn scan( + gpa: std.mem.Allocator, + file: []const u8, + text: []const u8, + out: *std.ArrayListUnmanaged(Hit), +) !usize { + var examined: usize = 0; + var it = std.mem.splitScalar(u8, text, '\n'); + var line_no: usize = 0; + while (it.next()) |raw| { + line_no += 1; + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + + // First token is the mnemonic. ANCHORED at the start of the trimmed + // line — never a substring search over the whole line. + const mnemonic_end = std.mem.indexOfAny(u8, line, " \t") orelse continue; + const mnemonic = line[0..mnemonic_end]; + + var is_call = false; + for (call_mnemonics) |m| { + if (std.mem.eql(u8, mnemonic, m)) { + is_call = true; + break; + } + } + if (!is_call) continue; + examined += 1; + + // Operand: the rest of the line up to a comment. The comma is NOT a + // terminator — `call qword ptr [rip + sym]` has none and an AArch64 + // `blr` operand may be a register list — so the whole operand is + // tokenised instead (see `operandNamesForbidden`). + var operand = std.mem.trim(u8, line[mnemonic_end..], " \t"); + for ([_][]const u8{ "//", "#", ";" }) |marker| { + if (std.mem.indexOf(u8, operand, marker)) |c| operand = operand[0..c]; + } + operand = std.mem.trim(u8, operand, " \t"); + if (operand.len == 0) continue; + + if (operandNamesForbidden(operand)) |name| { + try out.append(gpa, .{ + .file = file, + .line_no = line_no, + .line = line, + .symbol = name, + }); + } + } + return examined; +} + +/// Read a whole assembly listing into `gpa`. +fn readListing(gpa: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 { + const cwd = std.Io.Dir.cwd(); + var file = try cwd.openFile(io, path, .{}); + defer file.close(io); + const stat = try file.stat(io); + const buf = try gpa.alloc(u8, @intCast(stat.size)); + var reader = file.reader(io, &.{}); + try reader.interface.readSliceAll(buf); + return buf; +} + +pub fn main(init: std.process.Init) !void { + const gpa = init.arena.allocator(); + const io = init.io; + const args = try init.minimal.args.toSlice(gpa); + if (args.len < 2) { + std.debug.print("usage: asm_inventory [file.s ...]\n", .{}); + return error.NoInput; + } + + var hits: std.ArrayListUnmanaged(Hit) = .empty; + var total_calls: usize = 0; + + for (args[1..]) |path| { + const text = readListing(gpa, io, path) catch |err| { + std.debug.print("asm_inventory: cannot read {s}: {t}\n", .{ path, err }); + return err; + }; + const examined = try scan(gpa, path, text, &hits); + total_calls += examined; + std.debug.print("asm_inventory: {s} — {d} call sites examined\n", .{ path, examined }); + } + + // NON-VACUITY, enforced rather than hoped for. Reading a listing with no + // call instruction at all means the emit path changed under us (wrong + // artifact, empty file, a flag that stopped producing assembly), and a + // clean bill of health from such a run is worthless. Fail loudly instead. + if (total_calls == 0) { + std.debug.print( + "asm_inventory: FAIL — zero call sites across {d} file(s). " ++ + "The listings are empty or are not assembly; the inventory proved nothing.\n", + .{args.len - 1}, + ); + return error.NoCallSitesExamined; + } + + if (hits.items.len == 0) { + std.debug.print( + "asm_inventory: OK — no external transcendental in {d} call site(s) across {d} file(s).\n", + .{ total_calls, args.len - 1 }, + ); + return; + } + + std.debug.print("asm_inventory: FAIL — {d} external transcendental call(s):\n", .{hits.items.len}); + for (hits.items) |h| { + std.debug.print(" {s}:{d}: [{s}] {s}\n", .{ h.file, h.line_no, h.symbol, h.line }); + } + return error.ExternalTranscendentalFound; +} + +// --- Tests ------------------------------------------------------------------- + +const testing = std.testing; + +fn scanText(text: []const u8) !struct { hits: usize, examined: usize } { + var out: std.ArrayListUnmanaged(Hit) = .empty; + defer out.deinit(testing.allocator); + const examined = try scan(testing.allocator, "", text, &out); + return .{ .hits = out.items.len, .examined = examined }; +} + +/// Assert that every line in `cases` is flagged exactly once, NAMING the case +/// that failed. A loop under a bare `expectEqual` reports "expected 1, found 0" +/// and leaves the reader to find which of a dozen strings stopped matching. +fn expectAllFlagged(cases: []const []const u8) !void { + for (cases) |c| { + const r = try scanText(c); + if (r.hits != 1) { + std.debug.print("asm_inventory: case should have been flagged: {s}\n", .{c}); + return error.TestUnexpectedResult; + } + } +} + +/// The mirror: assert that no line in `cases` is flagged, naming the offender. +fn expectNoneFlagged(cases: []const []const u8) !void { + for (cases) |c| { + const r = try scanText(c); + if (r.hits != 0) { + std.debug.print("asm_inventory: case should NOT have been flagged: {s}\n", .{c}); + return error.TestUnexpectedResult; + } + } +} + +test "asm_inventory: flags a direct call, in each platform's symbol spelling" { + // THE counter-factual, first half. A scanner that never fires is + // indistinguishable from a clean tree. Each line is a spelling actually + // emitted by one of the three targets — the ELF PLT form, the Mach-O + // leading underscore, the Windows import thunk, the AArch64 + // branch-with-link — so this block owns exactly one mechanism: resolving a + // direct operand to a bare C identifier. + try expectAllFlagged(&.{ + "\tcallq\tcosf@PLT", + " call cos", + "\tcall\t_cosf", + "\tcall\t__imp_cos", + "\tbl\t_sinf", + "\tbl\tatan2", + "\tcallq\tfmodl", + "\tbl\tcbrt", + "\tcall\tlogf", + "\tbl\tacos", + "\tcall\ttanf", + "\tbl\tasin", + }); +} + +test "asm_inventory: flags a TAIL call, which an optimised leaf emits" { + // Its own block because it owns a DIFFERENT mechanism: the mnemonic table, + // not the operand parse. A leaf ending in `jmp cosf` has called `cosf` just + // as surely as one ending in `call cosf; ret`, and an inventory that knew + // only `call` would be blind to precisely the optimised shape the shipped + // build uses. Folded into the block above, a mnemonic table that lost `jmp` + // would still leave that block green on its other eleven rows. + try expectAllFlagged(&.{ + "\tjmp\tpowf@PLT", + "\tb\texp", + "\tjmpq\tsin", + "\tbr\tcosf", + }); +} + +test "asm_inventory: flags an INDIRECT call through the GOT" { + // Third mechanism: tokenising the operand rather than reading it as one + // symbol. `-fno-plt` turns every direct call into a load through the GOT, + // and a scanner that reads the operand whole misses all of these — a false + // negative on exactly the build flag a distribution is most likely to add. + try expectAllFlagged(&.{ + "\tcall\t*hypot@GOTPCREL(%rip)", + "\tcall\tqword ptr [rip + cosf@GOTPCREL]", + "\tcallq\t*sin@GOTPCREL(%rip)", + }); +} + +test "asm_inventory: flags a symbol behind more than one leading underscore" { + // Fourth mechanism: decoration stripping. A C library's internal alias + // carries two underscores where Mach-O prefixes one, so stripping exactly + // one would let `__sinf` through. + try expectAllFlagged(&.{ + "\tbl\t__sinf", + "\tcall\t__cos", + }); +} + +test "asm_inventory: does not flag a name that merely contains a forbidden one" { + // The reason matching is EXACT rather than by prefix. Every operand below is + // a real shape from a Zig listing; a prefix test flags all of them and the + // inventory acquires a suppression list within a week, at which point it has + // stopped meaning anything. + try expectNoneFlagged(&.{ + "\tcall\tcosine_table", + "\tcall\texpand_buffer", + "\tbl\tlogger_write", + "\tcall\tpowerset", + "\tcall\ttangent_basis", + "\tbl\tforge_3d.trig.cos", // a Zig namespace path, not the C symbol + "\tcall\t__zig_probe_stack", + "\tbl\tmemcpy", + "\tcall\tsqrt", // ARCH-031 rule 4 exempts @sqrt explicitly + }); +} + +test "asm_inventory: tokenising an operand does not invent a symbol from it" { + // The cost side of the indirect-call mechanism above, and its own claim: + // registers, size keywords and address arithmetic are tokens too, and none + // of them may be read as a function name. Folded into the block above, a + // tokeniser regression would be reported as a prefix-matching regression. + try expectNoneFlagged(&.{ + "\tcall\tqword ptr [rip + memcpy@GOTPCREL]", + "\tblr\tx16", + "\tcall\tqword ptr [rbx + 8]", + "\tbl\tx0", + }); +} + +test "asm_inventory: the mnemonic anchor rejects text that is not an instruction" { + // ONE mechanism — the anchor — sampled across the line kinds a listing + // actually contains. Kept as a single block for that reason, with the case + // named on failure: these are not independent claims, they are one claim + // under the shapes that would break it. + try expectNoneFlagged(&.{ + "\t.section\t.text.cos,\"ax\",@progbits", + "\t# call cosf here once, in 2023", + "cosf:", + "\t.ascii\t\"call exp\"", + "\t.type\tcos,@function", + "\tmovsd\tcos_table(%rip), %xmm0", + "// bl atan2 — an old comment", + }); +} + +test "asm_inventory: call sites are counted, so a silent empty listing is visible" { + // The count is the harness's own liveness signal — `main` refuses a run + // that examined zero call sites. This pins that the counter counts calls + // and not lines. + const listing = + "\t.text\n" ++ + "\tpushq\t%rbp\n" ++ + "\tcall\tmemcpy@PLT\n" ++ + "\tbl\tsome_zig_fn\n" ++ + "\tret\n"; + const r = try scanText(listing); + try testing.expectEqual(@as(usize, 2), r.examined); + try testing.expectEqual(@as(usize, 0), r.hits); + + const empty = try scanText("\t.text\n\tnop\n"); + try testing.expectEqual(@as(usize, 0), empty.examined); +} + +test "asm_inventory: the forbidden table is the invariant's list, all thirteen" { + // A count pin. `ARCH-031` rule 4 enumerates thirteen names; a table that + // silently loses one would leave a real call unreported and every test + // above would still pass, since none of them sweeps the table. + try testing.expectEqual(@as(usize, 13), forbidden.len); + for (forbidden) |name| { + try testing.expect(matchesForbidden(name) != null); + // …and each in its `f` and `l` spellings. + var buf: [16]u8 = undefined; + try testing.expect(matchesForbidden(try std.fmt.bufPrint(&buf, "{s}f", .{name})) != null); + try testing.expect(matchesForbidden(try std.fmt.bufPrint(&buf, "{s}l", .{name})) != null); + } +} diff --git a/tools/bindgen/core/emitter.zig b/tools/bindgen/core/emitter.zig index 295087f6..6cbe0801 100644 --- a/tools/bindgen/core/emitter.zig +++ b/tools/bindgen/core/emitter.zig @@ -53,16 +53,18 @@ pub fn emit( test "emit writes a placeholder for a minimal description" { const gpa = std.testing.allocator; - var buf: std.ArrayList(u8) = .empty; - defer buf.deinit(gpa); - var aw = buf.writer(gpa).adaptToNewApi(&.{}); + // M1.1.14 — `ArrayList.writer` was removed in Zig 0.16; `Io.Writer.Allocating` + // is the in-tree idiom (`src/modules/asset_pipeline/format/intermediate.zig`). + // This test had never compiled since the 0.16 pin: nothing collected it. + var aw = std.Io.Writer.Allocating.init(gpa); + defer aw.deinit(); const desc = api.ApiDescription{ .name = "vulkan", .version = .{ .major = 1, .minor = 3, .patch = 0 }, .source = .{ .xml_khronos = "bindings/upstream/vulkan/vk.xml" }, .link = .{ .name = .{ .runtime = .{ .linux = "", .windows = "", .macos = "" } } }, }; - try emit(desc, &aw.new_interface); - try std.testing.expect(std.mem.indexOf(u8, buf.items, "vulkan") != null); - try std.testing.expect(std.mem.indexOf(u8, buf.items, "skeleton") != null); + try emit(desc, &aw.writer); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "vulkan") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "skeleton") != null); } diff --git a/tools/bindgen/tests.zig b/tools/bindgen/tests.zig new file mode 100644 index 00000000..6d0cbc6d --- /dev/null +++ b/tools/bindgen/tests.zig @@ -0,0 +1,16 @@ +//! Test root for the bindgen tool's own unit tests. +//! +//! `main.zig` reaches its adapters through plain `const` imports, which a test +//! build does not analyse, so rooting a test target there collects NOTHING — +//! measured at M1.1.14: the target was added, the suite total moved by zero. +//! Only a `comptime` block that REFERENCES each import collects their tests. +//! Same trap as `tools/weld_lint/tests.zig` and `src/etch/root.zig`; see +//! `engine-zig-conventions.md` §13. + +comptime { + _ = @import("adapters/vk_xml/parser.zig"); + _ = @import("core/api_description.zig"); + _ = @import("core/emitter.zig"); + _ = @import("core/resolver.zig"); + _ = @import("core/validator.zig"); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig new file mode 100644 index 00000000..24963be8 --- /dev/null +++ b/tools/weld_lint/dead_tests.zig @@ -0,0 +1,1297 @@ +//! Dead-test analysis — every in-tree file holding a `test` block must belong to +//! the analysis closure of some test target, or be a DECLARED exclusion. +//! +//! WHY A STATIC CLOSURE AND NOT A COUNT. M1.1.14 hunted dead tests three times +//! with three methods; two failed. A per-binary enumeration blew a ten-minute +//! budget, and pairing the ordered spec list against the summary tree produced +//! 79 mismatches because that tree does not follow declaration order. What the +//! class needs is a check that builds nothing and runs nothing. +//! +//! WHY IT MATTERS MORE THAN SLEEPING ASSERTIONS. An uncollected `test` block is +//! never ANALYSED, so the code it instantiates gets no elaboration and no +//! type-checking. That one mechanism explains both areas the sweep found: +//! `zig_codegen/cache.zig` stopped compiling when `std.fs.cwd()` was removed at +//! Zig 0.16 and nobody learned it, and a use-after-return in the render graph +//! survived ten milestones. A dead test switches off COMPILATION coverage. +//! +//! THE EDGE CRITERION, and it is a criterion rather than a heuristic — it was +//! derived from measurements that discriminate all four observed cases: +//! +//! - `_ = @import("f.zig");` inside a `comptime` block → EDGE. +//! The explicit reference guard. In CODE: a commented-out one is not an edge, +//! and that mattered (see below). +//! - `const n = @import("f.zig");` (or `pub const`) where `n` is referenced +//! elsewhere in this root's closure → EDGE. This is why `src/etch/lexer.zig`'s +//! 17 tests are collected although nothing pins the file: `parser.zig` binds +//! it and uses it. +//! - `const n = @import("f.zig");` never referenced → NO EDGE. This is why +//! `zig_codegen/root.zig` was dead: `src/etch/root.zig` binds it as +//! `pub const codegen_zig` and never touches the name. +//! - `@import("f.zig").decl` used inline → decided by the REFERENCE like every +//! other form, never by the syntax. `pub const Graph = @import("graph.zig").Graph` +//! pulls that file's tests once `Graph` is referenced; `triangleIsFlat` pulled +//! nothing because nothing referenced it. Same syntax, opposite outcomes. +//! +//! It approximates Zig's lazy analysis, and the direction of its error matters: a +//! missed edge yields a false DEAD, noisy and visible; an invented edge yields a +//! false ALIVE, which says green. The hostile fixtures exist to refuse the second. +//! +//! THE CLOSURE IS PER ROOT, AND THAT IS THE CORRECTION THAT MADE IT BELIEVABLE. +//! A single closure over all roots at once let a reference made from ANOTHER +//! module license an edge inside this one: `tests/etch/keyword_ident_test.zig` +//! names `codegen_zig`, but it reaches it through the `weld_etch` MODULE, and +//! Zig collects no tests across a module boundary. Thirty-seven blocks that no +//! binary runs were counted live. Per root, growth is monotone — files only +//! enter, a reference counts only from a file already admitted HERE, and no two +//! files vouch for each other into the closure. +//! +//! `live_tests` is therefore the sum over roots, a MULTISET count: a file two +//! targets reach is counted twice, because the suite compiles and runs it twice. +//! That is what makes it comparable to the suite's own collected total. The dead +//! verdict is taken against the UNION — a file is dead only if NO root reaches it. +//! +//! TWO FALSE-ALIVE DEFECTS, both found by measurement and neither by the +//! fixtures, recorded because each is a class rather than an instance: +//! +//! 1. A BINDING COUNTED AS A REFERENCE TO ITSELF. The cross-file search +//! re-reads every live file including the one under analysis, passing +//! `osrc.len` as the binding-line start — a sentinel meaning "nothing to +//! skip". With the binding file as its own candidate the exclusion window +//! was empty, so `pub const codegen_zig = @import(…)` was its own +//! justification. The same-file test was POINTER IDENTITY, which never +//! fired: the production reader allocates a fresh buffer per call. It is now +//! by PATH. The fixture harness returned the map's own stable pointer, so +//! the bug was unreachable there — a harness differing from production in +//! the exact property under test agrees with the code instead of judging it. +//! It now allocates per call too. +//! 2. A COMMENTED-OUT IMPORT READ AS AN IMPORT. `src/etch/root.zig` shows the +//! guard that WOULD wire the subtree, `// _ = @import("zig_codegen/root.zig");`, +//! and the head of that line ends in `_ =`. The reference search had been +//! taught to skip comments earlier in this milestone, for the same file and +//! nearly the same sentence; the IMPORT site had not. A correction applied +//! at one site and not at its twin is the motif this repository sweeps. +//! +//! ROOT DISCOVERY IS ANCHORED ON THE WIRING, not on path syntax: a table is read +//! only when a `for` loop over it calls `addTest`, and a module's +//! `root_source_file` is read only inside that declaration's own initializer. +//! The previous forms invented roots out of `zig fmt` arguments and out of the +//! next declaration's literal. See `loopRoots` and `modulePath`. +//! +//! THE BILATERAL CONTROL IS WHAT AUTHORISES THIS GUARD, and it is not the fixture +//! count. Two independent computations must land on one number: this closure, and +//! `zig build test --summary all`. Too permissive overshoots, too strict +//! undershoots, and only equality excludes both — a property twenty-odd passing +//! fixtures did not have, and which caught a real defect on its first +//! application. Every term of the difference is declared IN ADVANCE in +//! `uncollected`: a predicted gap is a result, the same gap unannounced reads as +//! a broken guard. Measured on all three platforms of the matrix at the state +//! that activated it, closure 1857 in every case: +//! +//! macOS 1857 − 5 = 1852, suite reports 1852 +//! Linux 1857 − 5 = 1852, suite reports 1852 +//! Windows 1857 − 7 = 1850, suite reports 1850 +//! +//! The three reconcile exactly, and getting there refuted the hypothesis the gap +//! was first written on: `conv.zig` is collected on NO platform, not just macOS. +//! +//! Only relative `.zig` imports are followed. A module-name import (`std`, +//! `weld_core`) crosses into a module that owns its own test target and its own +//! closure — and, as the per-root correction above shows, its own references. + +const std = @import("std"); + +/// One file that holds `test` blocks but sits outside every closure. +pub const Dead = struct { + path: []const u8, + tests: usize, +}; + +/// What an analysis pass found, including the size of what it looked at. +/// +/// The counts are not decoration: this tool exists because a probe rendered a +/// verdict over an object it had not measured, four times in one milestone. A +/// report that says "0 dead" over 0 files examined is the same defect wearing +/// the tool's own badge. +pub const Report = struct { + /// Files reached from at least one root. + in_closure: usize = 0, + /// Files under the scanned roots holding at least one `test` block. + with_tests: usize = 0, + /// Test blocks counted in the closure. + live_tests: usize = 0, + /// Files holding tests and outside every closure, excluding declarations. + dead: std.ArrayList(Dead) = .empty, + /// Files matched by a declared exclusion, reported and not failed on. + excluded: usize = 0, + /// Test blocks inside declared exclusions. + excluded_tests: usize = 0, + /// Every closure file with its test count, so a delta can be DECOMPOSED. + closure: std.ArrayList(Dead) = .empty, + + pub fn deinit(self: *Report, gpa: std.mem.Allocator) void { + for (self.dead.items) |d| gpa.free(d.path); + self.dead.deinit(gpa); + for (self.closure.items) |c| gpa.free(c.path); + self.closure.deinit(gpa); + } +}; + +/// A deliberate refusal to elaborate a subtree, with the milestone that owns it. +/// +/// An exclusion is DECLARED, never silent: that is what separates a known debt +/// from a hidden one, and it is why `zig_codegen` has a legitimate status here +/// rather than an embarrassed absence. +pub const Exclusion = struct { + prefix: []const u8, + reason: []const u8, + owner: []const u8, +}; + +/// The tree's declared exclusions. +pub const exclusions = [_]Exclusion{ + .{ + .prefix = "src/etch/zig_codegen/", + .reason = "the subtree IS elaborated — two live test targets reach `codegen_zig` through " ++ + "the `weld_etch` module boundary — but the subset the wire-in ADDS does not compile: " ++ + "`zig_codegen/tests/` and `cache.zig`, where `std.fs.cwd()` was removed at Zig 0.16 " ++ + "and the replacement takes an `io` parameter these functions do not have, so the " ++ + "repair changes the codegen cache's public signatures", + .owner = "M1.D.5", + }, +}; + +/// A file INSIDE the closure whose blocks `zig build test` does not collect. +/// +/// The closure count and the suite's collected total are two independent +/// computations, and their agreement is what authorises this guard. They are not +/// equal, and every term of the difference is declared HERE, in advance: a +/// predicted gap is a result, while the same gap unannounced reads as a broken +/// guard and nobody activates it. An undeclared gap is exactly what the control +/// exists to surface. +/// +/// This is a different list from `exclusions`. An exclusion is a subtree no +/// closure reaches at all; an entry here is reached, counted, and then not +/// collected — for a reason that lives in the compiler or in `build.zig`, never +/// in this analysis. +pub const Uncollected = struct { + path: []const u8, + blocks: usize, + /// `null` means every platform. + only_on: ?std.Target.Os.Tag = null, + reason: []const u8, +}; + +/// The declared terms of the closure-versus-suite gap, one entry per mechanism. +pub const uncollected = [_]Uncollected{ + .{ + .path = "src/modules/render/gal/vulkan/conv.zig", + .blocks = 4, + .reason = "reached by this analysis through `gal/root.zig`'s `pub const " ++ + "vulkan_backend`, and elaborated by Zig on NO platform of the matrix: the " ++ + "render step reports 45 collected against a closure of 49 on macOS, on " ++ + "ubuntu-24.04 and on windows-2025 alike. MEASURED, and it REFUTES the standing " ++ + "hypothesis that the macOS Null backend was the cause and that Linux and " ++ + "Windows would collect these four — they do not. What is established is the " ++ + "absence, on all three; the mechanism inside Zig's lazy analysis is not, and " ++ + "is deliberately not guessed at here", + }, + .{ + .path = "src/core/ipc/shm_posix.zig", + .blocks = 1, + .only_on = .windows, + .reason = "selected by comptime dispatch inside `shm.zig` and not analysed on " ++ + "Windows. This analysis is static and follows the import regardless", + }, + .{ + .path = "src/core/ipc/transport_posix.zig", + .blocks = 1, + .only_on = .windows, + .reason = "same comptime dispatch as `shm_posix.zig`. The pair is exactly the " ++ + "Linux-minus-Windows gap: the `core` step reports 167 collected on " ++ + "ubuntu-24.04 and 165 on windows-2025, and that ONE step is the only " ++ + "difference between the two cells across all 129", + }, + .{ + .path = "tests/ecs/no_alloc_steady_state_stress.zig", + .blocks = 1, + .reason = "the M0.2.1/E2 scheduler-livelock stress test hangs off `zig build " ++ + "test-stress` and is deliberately kept OUT of `zig build test` — its own " ++ + "`build.zig` comment says so. THE FIFTH BLOCK of the M1.1.14 sweep, which two " ++ + "attempts failed to localise: it was never dead and never missing, it was in a " ++ + "step the suite does not run", + }, +}; + +/// Blocks the closure counts that `zig build test` does not collect, on `os`. +/// The number of test blocks `zig build test` COLLECTS on each platform. +/// +/// **THIS IS THE OTHER SIDE OF THE CONSERVATION, and until M1.1.14's review there +/// was no other side.** `uncollectedOn` gives the declared gap, so +/// `live_tests - gap` yields an EXPECTED collected total — and the tool printed +/// that expectation and then printed `clean`, having compared it to nothing. The +/// confrontation existed only behind an optional `--expect-collected=N` that +/// neither `build.zig` nor the CI ever passed. A control that a path can bypass is +/// not a control, and this one was the guard built AGAINST that very class. +/// +/// **WHY A DECLARED NUMBER AND NOT A DERIVED ONE.** The tool cannot run the suite; +/// it reads source. So the unconditional check confronts the closure against a +/// number a human wrote down, which catches CLOSURE drift on every invocation with +/// no flag to forget. What stops that number from being bumped to match a drifted +/// closure is the SECOND layer: CI parses `zig build test`'s own reported total and +/// passes it through `--expect-collected`, so the declared number is itself +/// confronted with what the suite actually ran. Two independently produced numbers, +/// which is the definition the bilateral control has carried since it was written. +/// +/// **Windows is two lower** and the two blocks are the `only_on = .windows` entries +/// above — `shm_posix.zig` and `transport_posix.zig`. That is arithmetic on this +/// same table and not a second measurement, which is why the CI layer matters. +pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { + // Reconciled against `zig build test --summary all`, which reported 1869 + // collected — NOT bumped to match the closure's own arithmetic, which is the + // repair the failure message forbids. Windows is two lower, the two + // `only_on = .windows` entries above. + // + // This control has now stopped two commits in a row on its first real uses, each + // time on a genuine test addition, and each time the number was re-derived from + // the suite rather than from the closure. That is the whole point of it. + return switch (os) { + .windows => 1867, + else => 1869, + }; +} + +/// Blocks the closure counts that `zig build test` does not collect, on `os`. +pub fn uncollectedOn(os: std.Target.Os.Tag) usize { + var n: usize = 0; + for (uncollected) |u| { + if (u.only_on == null or u.only_on.? == os) n += u.blocks; + } + return n; +} + +/// Whether `path` falls under a declared exclusion. +pub fn excludedBy(path: []const u8) ?Exclusion { + for (exclusions) |e| { + if (std.mem.indexOf(u8, path, e.prefix) != null) return e; + } + return null; +} + +/// Counts the top-level `test` declarations in `source`. +pub fn countTests(source: []const u8) usize { + var n: usize = 0; + var it = std.mem.splitScalar(u8, source, '\n'); + while (it.next()) |line| { + const t = std.mem.trimStart(u8, line, " \t"); + if (!std.mem.startsWith(u8, t, "test")) continue; + if (t.len == 4) continue; + const c = t[4]; + if (c != ' ' and c != '\t') continue; + const rest = std.mem.trimStart(u8, t[4..], " \t"); + if (rest.len == 0) continue; + if (rest[0] == '"' or rest[0] == '{' or std.ascii.isAlphabetic(rest[0]) or rest[0] == '_') n += 1; + } + return n; +} + +/// One outgoing edge: the relative import path a file's analysis reaches. +const Edge = struct { rel: []const u8 }; + +/// Extracts the edges of `source` under the criterion documented at the top. +/// +/// Two passes, because the second criterion needs to know whether a bound name +/// is used and a name can be used before its binding is read. +fn edgesOf(gpa: std.mem.Allocator, source: []const u8, out: *std.ArrayList(Edge)) !void { + // Pass 1 — every `@import("…")` occurrence, with the binding name if any and + // whether it is consumed inline by a field access. + var i: usize = 0; + while (std.mem.indexOfPos(u8, source, i, "@import(\"")) |at| { + const start = at + "@import(\"".len; + const end = std.mem.indexOfScalarPos(u8, source, start, '"') orelse break; + const rel = source[start..end]; + i = end + 1; + if (!std.mem.endsWith(u8, rel, ".zig")) continue; + // A COMMENTED-OUT import is not an import. See `edgesOfLive`. + if (inComment(source, at)) continue; + + // Find the statement head to classify the binding. + const line_start = if (std.mem.lastIndexOfScalar(u8, source[0..at], '\n')) |p| p + 1 else 0; + const head = std.mem.trim(u8, source[line_start..at], " \t"); + + // `head` is trimmed on BOTH ends, so the trailing space of `_ = ` is gone. + // Comparing against `"_ = "` matched nothing and reported the pinned file + // dead — caught by this file's own fixture, which is what they are for. + if (std.mem.endsWith(u8, head, "_ =")) { + try out.append(gpa, .{ .rel = rel }); // explicit reference guard + continue; + } + // The DISCRIMINANT IS THE REFERENCE, never the syntax of the import. + // An earlier version refused `@import("f.zig").decl` outright, which is + // wrong: `pub const Graph = @import("graph.zig").Graph;` DOES pull that + // file's tests once `Graph` is referenced, while `triangleIsFlat` pulled + // nothing because nothing referenced it. Same syntax, opposite outcomes, + // one rule. So the syntax test folds INTO the reference test rather than + // preceding it, and an unbound inline access simply has no name to check. + const name = bindingName(head) orelse continue; + if (isReferenced(source, name, line_start)) try out.append(gpa, .{ .rel = rel }); + } +} + +/// Edges of `source` where a bound name counts as referenced if it appears in +/// `source` itself OR in any OTHER file of `live` — and in nothing else. +/// +/// `path` is the file `source` was read from, and it is what identifies it. +/// THE SAME-FILE TEST USED TO BE POINTER IDENTITY, `osrc.ptr == source.ptr`, +/// AND IT NEVER FIRED IN PRODUCTION: the real reader allocates a fresh buffer on +/// every call, so re-reading the very file under analysis yielded a different +/// pointer. The cross-file loop then scanned the binding file as though it were +/// another file, with `binding_line_start = osrc.len` — a sentinel meaning "no +/// binding line to skip" — so the empty exclusion window let the binding line +/// count as a reference to itself. `pub const codegen_zig = @import(…)` was +/// therefore its own justification, and all of `src/etch/zig_codegen/` entered +/// the closure: a SELF-REFERENTIAL rule, and the false-ALIVE direction. +/// +/// The fixtures could not see it. `Fixture.read` returns a stable pointer out of +/// a hash map, so pointer identity worked there and only there — a harness that +/// differed from production in the exact property under test. It now returns a +/// fresh copy per call, like the real reader, and the comparison is by PATH, +/// which is correct whatever the reader does with memory. +fn edgesOfLive( + gpa: std.mem.Allocator, + path: []const u8, + source: []const u8, + live: []const []const u8, + read: *const fn (path: []const u8) ?[]const u8, + out: *std.ArrayList(Edge), +) !void { + var i: usize = 0; + while (std.mem.indexOfPos(u8, source, i, "@import(\"")) |at| { + const start = at + "@import(\"".len; + const end = std.mem.indexOfScalarPos(u8, source, start, '"') orelse break; + const rel = source[start..end]; + i = end + 1; + if (!std.mem.endsWith(u8, rel, ".zig")) continue; + // A COMMENTED-OUT import is not an import, and this is the edge that + // admitted all of `src/etch/zig_codegen/`. `src/etch/root.zig` carries + // `// _ = @import("zig_codegen/root.zig");` — a commented-out EXAMPLE + // of the reference guard, in the paragraph explaining why the subtree is + // held — and the head of that line ends in `_ =`, so it was read as the + // guard itself. Thirty-seven blocks entered the closure on the strength + // of a comment, which is the false-ALIVE direction: it says green. + // + // The reference search was taught to skip comments earlier in this + // milestone, for the same file and very nearly the same sentence. The + // IMPORT site was not, and a correction applied at one site and not at + // its twin is the motif this repository sweeps rather than patches. + if (inComment(source, at)) continue; + + const line_start = if (std.mem.lastIndexOfScalar(u8, source[0..at], '\n')) |p| p + 1 else 0; + const head = std.mem.trim(u8, source[line_start..at], " \t"); + if (std.mem.endsWith(u8, head, "_ =")) { + try out.append(gpa, .{ .rel = rel }); + continue; + } + const name = bindingName(head) orelse { + // A binding the head parser does not recognise — an `@import` inside a + // `switch` assigned to a referenced `const`, as `ipc/transport.zig` + // does. Refusing it outright reported a live file dead; admitting it + // whenever the enclosing statement binds SOMETHING keeps the criterion + // (a reference exists) without teaching the parser every expression form. + if (enclosingBindingReferenced(path, source, line_start, live, read)) { + try out.append(gpa, .{ .rel = rel }); + } + continue; + }; + if (isReferenced(source, name, line_start)) { + try out.append(gpa, .{ .rel = rel }); + continue; + } + for (live) |other| { + if (std.mem.eql(u8, other, path)) continue; + const osrc = read(other) orelse continue; + if (isReferenced(osrc, name, osrc.len)) { + try out.append(gpa, .{ .rel = rel }); + break; + } + } + } +} + +/// Whether the `const NAME = ` statement enclosing `line_start` binds a name that +/// is referenced in `source` or in the live set. Walks back to the nearest +/// `const … = ` line, which is where a multi-line `switch` binding starts. +fn enclosingBindingReferenced( + path: []const u8, + source: []const u8, + line_start: usize, + live: []const []const u8, + read: *const fn (path: []const u8) ?[]const u8, +) bool { + var pos = line_start; + var back: usize = 0; + while (pos > 0 and back < 8) : (back += 1) { + const prev_end = pos - 1; + const prev_start = if (std.mem.lastIndexOfScalar(u8, source[0..prev_end], '\n')) |p| p + 1 else 0; + const line = std.mem.trim(u8, source[prev_start..prev_end], " \t"); + if (bindingName(line)) |name| { + if (isReferenced(source, name, prev_start)) return true; + for (live) |other| { + if (std.mem.eql(u8, other, path)) continue; + const osrc = read(other) orelse continue; + if (isReferenced(osrc, name, osrc.len)) return true; + } + return false; + } + pos = prev_start; + if (prev_start == 0) break; + } + return false; +} + +/// The bound name of `const NAME = ` / `pub const NAME = `, or null. +fn bindingName(head: []const u8) ?[]const u8 { + var h = head; + if (std.mem.startsWith(u8, h, "pub ")) h = std.mem.trimStart(u8, h["pub ".len..], " \t"); + if (!std.mem.startsWith(u8, h, "const ")) return null; + h = std.mem.trimStart(u8, h["const ".len..], " \t"); + const eq = std.mem.indexOfScalar(u8, h, '=') orelse return null; + const name = std.mem.trim(u8, h[0..eq], " \t"); + if (name.len == 0) return null; + for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_') return null; + return name; +} + +/// Whether a byte offset falls inside a `//` comment. +/// +/// MEASURED, and by the worst possible route: the guard admitted all of +/// `src/etch/zig_codegen/` because `src/etch/root.zig` carries a COMMENT naming +/// `codegen_zig` — the very paragraph explaining why that subtree is dead. The +/// documentation of a corpse reported it alive. A reference search that reads +/// prose is not a reference search. +fn inComment(source: []const u8, at: usize) bool { + const line_start = if (std.mem.lastIndexOfScalar(u8, source[0..at], '\n')) |p| p + 1 else 0; + var i = line_start; + while (i + 1 < at) : (i += 1) { + if (source[i] == '/' and source[i + 1] == '/') return true; + if (source[i] == '"') return false; // a `//` inside a string is not a comment + } + return false; +} + +/// Whether `name` appears as an identifier anywhere outside its own binding line, +/// ignoring occurrences inside comments. +fn isReferenced(source: []const u8, name: []const u8, binding_line_start: usize) bool { + const binding_line_end = std.mem.indexOfScalarPos(u8, source, binding_line_start, '\n') orelse source.len; + var i: usize = 0; + while (std.mem.indexOfPos(u8, source, i, name)) |at| { + i = at + name.len; + if (at >= binding_line_start and at < binding_line_end) continue; // its own binding + const before_ok = at == 0 or !isIdentChar(source[at - 1]); + const after = at + name.len; + const after_ok = after >= source.len or !isIdentChar(source[after]); + if (before_ok and after_ok and !inComment(source, at)) return true; + } + return false; +} + +fn isIdentChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_'; +} + +/// Resolves `rel` against the directory of `from`, normalising `..` segments. +fn resolveRel(gpa: std.mem.Allocator, from: []const u8, rel: []const u8) ![]u8 { + const dir = std.fs.path.dirname(from) orelse "."; + const joined = try std.fs.path.join(gpa, &.{ dir, rel }); + defer gpa.free(joined); + return normalise(gpa, joined); +} + +/// Collapses `a/b/../c` to `a/c` and normalises separators to `/`. +fn normalise(gpa: std.mem.Allocator, path: []const u8) ![]u8 { + var parts: std.ArrayList([]const u8) = .empty; + defer parts.deinit(gpa); + var it = std.mem.splitAny(u8, path, "/\\"); + while (it.next()) |seg| { + if (seg.len == 0 or std.mem.eql(u8, seg, ".")) continue; + if (std.mem.eql(u8, seg, "..")) { + if (parts.items.len > 0) _ = parts.pop(); + continue; + } + try parts.append(gpa, seg); + } + return std.mem.join(gpa, "/", parts.items); +} + +/// One root's closure: the files reached, and their `test` block count. +const RootClosure = struct { + files: std.ArrayList([]const u8) = .empty, + tests: usize = 0, + + fn deinit(self: *RootClosure, gpa: std.mem.Allocator) void { + for (self.files.items) |f| gpa.free(f); + self.files.deinit(gpa); + } +}; + +/// The closure of ONE root, by monotone growth. +/// +/// SCOPED TO THIS ROOT, and that scope is the whole correction. The cross-file +/// reference that makes a binding live — `gal/root.zig` binds `pub const +/// barriers`, `render_graph/pass.zig` writes `gal.barriers.Access` — is real, but +/// it is only meaningful WITHIN one module. Run over every root at once, the +/// same rule let `tests/etch/keyword_ident_test.zig` license an edge inside +/// `src/etch/`: that file names `codegen_zig`, but it reaches it through the +/// `weld_etch` MODULE, and Zig collects no tests across a module boundary. So a +/// reference from another root's closure is not evidence about this one, and +/// counting it admitted 37 blocks that no binary ever runs. +/// +/// Per root, the growth is monotone: files only ever enter, a reference only +/// counts when it comes from a file already admitted HERE, and there is no rule +/// by which two files vouch for each other into the closure. +fn closureOf( + gpa: std.mem.Allocator, + root: []const u8, + read: *const fn (path: []const u8) ?[]const u8, +) !RootClosure { + var out: RootClosure = .{}; + errdefer out.deinit(gpa); + + var seen: std.StringHashMapUnmanaged(void) = .empty; + defer seen.deinit(gpa); + + const first = try normalise(gpa, root); + try seen.put(gpa, first, {}); + try out.files.append(gpa, first); + + while (true) { + // Admissions are STAGED and appended after the scan. Appending inside the + // loop reallocates `files.items` and leaves the loop variable dangling + // into freed memory — which segfaulted on Zig's `0xaa` poison at the + // first real run. + var pending: std.ArrayList([]const u8) = .empty; + defer pending.deinit(gpa); + for (out.files.items) |path| { + const src = read(path) orelse continue; + var edges: std.ArrayList(Edge) = .empty; + defer edges.deinit(gpa); + try edgesOfLive(gpa, path, src, out.files.items, read, &edges); + for (edges.items) |e| { + const resolved = try resolveRel(gpa, path, e.rel); + if (seen.contains(resolved)) { + gpa.free(resolved); + continue; + } + try seen.put(gpa, resolved, {}); + try pending.append(gpa, resolved); + } + } + if (pending.items.len == 0) break; + for (pending.items) |r| try out.files.append(gpa, r); + } + + for (out.files.items) |path| { + const src = read(path) orelse continue; + out.tests += countTests(src); + } + return out; +} + +/// Walks each root's closure and reports every file with tests outside all of them. +/// +/// `read` supplies file contents so the analysis is testable against fixtures +/// without touching the filesystem layout the tool normally walks. +/// +/// `live_tests` is the sum over roots and therefore a MULTISET count: a file two +/// targets both reach is counted twice, because the suite compiles it twice and +/// runs its tests twice. That is what makes it comparable to the suite's own +/// collected total. `in_closure` and `closure` are the UNION, which is the right +/// basis for the dead verdict — a file is dead only if no root reaches it. +pub fn analyze( + gpa: std.mem.Allocator, + roots: []const []const u8, + all_files: []const []const u8, + read: *const fn (path: []const u8) ?[]const u8, +) !Report { + var report: Report = .{}; + errdefer report.deinit(gpa); + + var seen: std.StringHashMapUnmanaged(void) = .empty; + defer { + var it = seen.keyIterator(); + while (it.next()) |k| gpa.free(k.*); + seen.deinit(gpa); + } + + for (roots) |r| { + var one = try closureOf(gpa, r, read); + defer one.deinit(gpa); + report.live_tests += one.tests; + for (one.files.items) |f| { + if (seen.contains(f)) continue; + const owned = try gpa.dupe(u8, f); + try seen.put(gpa, owned, {}); + report.in_closure += 1; + const src = read(f) orelse continue; + try report.closure.append(gpa, .{ .path = try gpa.dupe(u8, f), .tests = countTests(src) }); + } + } + + for (all_files) |f| { + const src = read(f) orelse continue; + const n = countTests(src); + if (n == 0) continue; + report.with_tests += 1; + const norm = try normalise(gpa, f); + defer gpa.free(norm); + if (seen.contains(norm)) continue; + if (excludedBy(norm)) |_| { + report.excluded += 1; + report.excluded_tests += n; + continue; + } + try report.dead.append(gpa, .{ .path = try gpa.dupe(u8, norm), .tests = n }); + } + return report; +} + +// --------------------------------------------------------------------------- +// Tests — the hostile fixtures the criterion is only believable with +// --------------------------------------------------------------------------- + +const Fixture = struct { + var files: std.StringHashMapUnmanaged([]const u8) = .empty; + var copies: std.ArrayList([]u8) = .empty; + var gpa: std.mem.Allocator = undefined; + + /// Returns a FRESH buffer on every call, exactly as the production reader + /// does. It used to hand back the map's own stable pointer, and that single + /// difference is why no fixture could see the self-reference defect: a + /// same-file test written as pointer identity passed here and never fired in + /// the tree. A harness that differs from production in the property under + /// test agrees with the code instead of judging it. + fn read(path: []const u8) ?[]const u8 { + const src = files.get(path) orelse return null; + const copy = gpa.dupe(u8, src) catch return null; + copies.append(gpa, copy) catch { + gpa.free(copy); + return null; + }; + return copy; + } + + fn deinit(a: std.mem.Allocator) void { + for (copies.items) |c| a.free(c); + copies.deinit(a); + copies = .empty; + files.deinit(a); + files = .empty; + } +}; + +fn runFixture( + gpa: std.mem.Allocator, + entries: []const [2][]const u8, + roots: []const []const u8, +) !Report { + Fixture.files = .empty; + Fixture.copies = .empty; + Fixture.gpa = gpa; + for (entries) |e| try Fixture.files.put(gpa, e[0], e[1]); + var names: std.ArrayList([]const u8) = .empty; + defer names.deinit(gpa); + for (entries) |e| try names.append(gpa, e[0]); + return analyze(gpa, roots, names.items, &Fixture.read); +} + +test "a test three imports deep is ALIVE" { + // The false-DEAD direction. Each hop binds a name AND references it, which + // is the ordinary way a module reaches its files; a closure that stops short + // would condemn most of the tree. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "const a = @import(\"a.zig\");\npub const A = a.T;\n" }, + .{ "m/a.zig", "const b = @import(\"b.zig\");\npub const T = b.U;\n" }, + .{ "m/b.zig", "const c = @import(\"c.zig\");\npub const U = c.V;\n" }, + .{ "m/c.zig", "pub const V = u8;\ntest \"deep\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 4), r.in_closure); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} + +test "a file bound but never referenced is DEAD" { + // The false-ALIVE direction, and the one that kills: it says green. This is + // `zig_codegen` exactly — `pub const codegen_zig = @import(...)`, never used. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const orphan = @import(\"orphan.zig\");\n" }, + .{ "m/orphan.zig", "test \"never analysed\" {}\ntest \"nor this\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 1), r.dead.items.len); + try std.testing.expectEqualStrings("m/orphan.zig", r.dead.items[0].path); + try std.testing.expectEqual(@as(usize, 2), r.dead.items[0].tests); +} + +test "an inline field access whose name is NEVER referenced is DEAD" { + // `foundation/math/exact.zig` exactly: bound as + // `pub const triangleIsFlat = @import("exact.zig").triangleIsFlat;` and + // referenced by nothing, so its two tests never ran. + // + // This fixture ORIGINALLY bound `f` and then wrote `pub const g = f;`, which + // references it — it therefore encoded the refuted rule (syntax decides) and + // passed only because the implementation shared the mistake. Corrected here + // with its sibling below, which is the same syntax with the reference present. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const f = @import(\"leaf.zig\").f;\n" }, + .{ "m/leaf.zig", "pub fn f() void {}\ntest \"leaf\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 1), r.dead.items.len); + try std.testing.expectEqualStrings("m/leaf.zig", r.dead.items[0].path); +} + +test "an explicit comptime reference guard is an edge" { + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "comptime {\n _ = @import(\"pinned.zig\");\n}\n" }, + .{ "m/pinned.zig", "test \"pinned\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} + +test "a declared exclusion is reported, not failed on" { + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const x = @import(\"src/etch/zig_codegen/cache.zig\");\n" }, + .{ "src/etch/zig_codegen/cache.zig", "test \"excluded\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.excluded); + try std.testing.expectEqual(@as(usize, 1), r.excluded_tests); +} + +test "relative parent segments resolve" { + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/sub/root.zig", "const up = @import(\"../up.zig\");\npub const U = up.U;\n" }, + .{ "m/up.zig", "pub const U = u8;\ntest \"up\" {}\n" }, + }, &.{"m/sub/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} + +test "countTests counts declarations and not prose" { + try std.testing.expectEqual(@as(usize, 2), countTests("test \"a\" {}\ntest {}\n")); + try std.testing.expectEqual(@as(usize, 0), countTests("// test \"a\" {}\n/// test {}\n")); + try std.testing.expectEqual(@as(usize, 0), countTests("const testing = 1;\ntesting_only();\n")); +} + +// --------------------------------------------------------------------------- +// Root discovery +// --------------------------------------------------------------------------- + +/// Extracts every `addTest` root source path from `build_zig`. +/// +/// DERIVED, never duplicated. A hand-kept list beside `build.zig` would drift +/// the first time a target is added, and drift here reads as "no dead tests" — +/// the failure that says green. Two shapes are matched: `b.createModule` and +/// `b.addModule`, both keyed by the variable `addTest` roots at. +pub fn rootsFromBuildZig(gpa: std.mem.Allocator, build_zig: []const u8) !std.ArrayList([]const u8) { + var roots: std.ArrayList([]const u8) = .empty; + errdefer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + var i: usize = 0; + while (std.mem.indexOfPos(u8, build_zig, i, ".root_module = ")) |at| { + const name_start = at + ".root_module = ".len; + var name_end = name_start; + while (name_end < build_zig.len and isIdentChar(build_zig[name_end])) name_end += 1; + i = name_end; + // Only the ones an `addTest` roots at. + const line_start = if (std.mem.lastIndexOfScalar(u8, build_zig[0..at], '\n')) |p| p + 1 else 0; + if (std.mem.indexOf(u8, build_zig[line_start..at], "addTest") == null) continue; + const mod = build_zig[name_start..name_end]; + if (try modulePath(gpa, build_zig, mod)) |p| try roots.append(gpa, p); + } + try loopRoots(gpa, build_zig, &roots); + return roots; +} + +/// Extracts the literal paths of every table a loop turns into test targets. +/// +/// A target whose `root_source_file` is a LOOP VARIABLE has no literal to find +/// at its `createModule`, so the first version of this file missed every one of +/// them and reported all of `tests/` dead. The paths are still literals — in the +/// table the loop walks — so they are read from there. +/// +/// ANCHORED ON THE WIRING, NOT ON THE PATH SYNTAX, and that is a correction. +/// Two shapes used to be matched independently — `.path = "…"` entries anywhere, +/// and any line that was a quoted `.zig` path followed by a comma — neither of +/// them scoped to a table that feeds `addTest`. The second INVENTED THREE ROOTS: +/// the arguments of a `zig fmt` `addSystemCommand`, which are exactly that shape +/// and are not test roots at all. An invented root is the false-ALIVE direction +/// — it silently admits whatever it reaches and can mask a genuinely dead file — +/// and the fixture that was supposed to catch it only checked that a +/// `b.path("…")` call was NOT matched, a different syntax, so it agreed with the +/// implementation's blind spot instead of testing it. +/// +/// The rule is now one rule and it asks the question that actually decides: +/// does a `for` loop over this table build test targets? A table is read only +/// when its name is the subject of a loop whose body calls `addTest`, and then +/// EVERY quoted `.zig` literal inside the table is taken — which covers both +/// shapes without naming either. +pub fn loopRoots(gpa: std.mem.Allocator, build_zig: []const u8, out: *std.ArrayList([]const u8)) !void { + var i: usize = 0; + while (std.mem.indexOfPos(u8, build_zig, i, "for (")) |at| { + const s = at + "for (".len; + var e = s; + while (e < build_zig.len and isIdentChar(build_zig[e])) e += 1; + i = if (e > s) e else s + 1; + if (e == s or e >= build_zig.len or build_zig[e] != ')') continue; + const name = build_zig[s..e]; + const body = loopBody(build_zig, e) orelse continue; + if (std.mem.indexOf(u8, body, "addTest") == null) continue; + try tableLiterals(gpa, build_zig, name, out); + } +} + +/// The text of the block opened by the first `{` after `at`, brace-matched. +/// +/// Strings and line comments are skipped, so a `"{s}"` in a format call or a +/// brace inside a comment cannot close the body early and truncate the +/// `addTest` search into a false negative. +fn loopBody(src: []const u8, at: usize) ?[]const u8 { + const open = std.mem.indexOfScalarPos(u8, src, at, '{') orelse return null; + var depth: usize = 0; + var i = open; + while (i < src.len) : (i += 1) { + switch (src[i]) { + '"' => { + i += 1; + while (i < src.len and src[i] != '"') : (i += 1) { + if (src[i] == '\\') i += 1; + } + }, + '/' => { + if (i + 1 < src.len and src[i + 1] == '/') { + i = std.mem.indexOfScalarPos(u8, src, i, '\n') orelse return null; + } + }, + '{' => depth += 1, + '}' => { + depth -= 1; + if (depth == 0) return src[open .. i + 1]; + }, + else => {}, + } + } + return null; +} + +/// Appends every quoted `.zig` literal of the array bound to `name`. +fn tableLiterals( + gpa: std.mem.Allocator, + build_zig: []const u8, + name: []const u8, + out: *std.ArrayList([]const u8), +) !void { + var buf: [128]u8 = undefined; + const decl = std.fmt.bufPrint(&buf, "const {s} = ", .{name}) catch return; + const at = std.mem.indexOf(u8, build_zig, decl) orelse return; + const body = loopBody(build_zig, at + decl.len) orelse return; + + var i: usize = 0; + while (std.mem.indexOfScalarPos(u8, body, i, '"')) |q| { + const end = std.mem.indexOfScalarPos(u8, body, q + 1, '"') orelse return; + i = end + 1; + const lit = body[q + 1 .. end]; + if (!std.mem.endsWith(u8, lit, ".zig")) continue; + for (out.items) |existing| { + if (std.mem.eql(u8, existing, lit)) break; + } else try out.append(gpa, try gpa.dupe(u8, lit)); + } +} + +/// The `root_source_file` path of the module bound to `name`, if it is a literal. +/// +/// SCOPED TO THE DECLARATION'S OWN INITIALIZER, by brace matching. It used to +/// search forward from the declaration under a 400-character window, which is a +/// window and not a boundary: when a module's `root_source_file` is NOT a literal +/// — `b.path(spec.path)` in the `test_specs` loop, the ordinary shape — the +/// search ran on and took the literal of the NEXT declaration, inventing a root +/// out of whatever came after. That is the direction that says ALIVE, since an +/// invented root admits its whole closure. +/// +/// It did not fire on the current `build.zig`, measured: the loop's module is +/// followed by enough `addImport` lines to push the next literal past 400 bytes. +/// One reordering away from firing, and found by a fixture written for a +/// different defect — which is the argument for a boundary rather than a window. +fn modulePath(gpa: std.mem.Allocator, build_zig: []const u8, name: []const u8) !?[]u8 { + var buf: [128]u8 = undefined; + const decl = std.fmt.bufPrint(&buf, "const {s} = b.", .{name}) catch return null; + const at = std.mem.indexOf(u8, build_zig, decl) orelse return null; + const init_block = loopBody(build_zig, at + decl.len) orelse return null; + const key = std.mem.indexOf(u8, init_block, "root_source_file = b.path(\"") orelse return null; + const s = key + "root_source_file = b.path(\"".len; + const e = std.mem.indexOfScalarPos(u8, init_block, s, '"') orelse return null; + return try gpa.dupe(u8, init_block[s..e]); +} + +test "rootsFromBuildZig picks addTest roots and skips other modules" { + const gpa = std.testing.allocator; + const src = + \\const a_module = b.createModule(.{ + \\ .root_source_file = b.path("src/a/root.zig"), + \\}); + \\const not_a_test = b.createModule(.{ + \\ .root_source_file = b.path("src/never/root.zig"), + \\}); + \\const exe = b.addExecutable(.{ .root_module = not_a_test }); + \\const a_tests = b.addTest(.{ .root_module = a_module }); + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 1), roots.items.len); + try std.testing.expectEqualStrings("src/a/root.zig", roots.items[0]); +} + +test "a non-literal root_source_file does not borrow the next declaration's" { + // The window-versus-boundary defect, pinned on its own. `t_mod` roots at a + // loop variable, so it has no literal of its own; the declaration that + // FOLLOWS it does. Under the old 400-character window that literal was taken + // and `src/runtime/main.zig` — an executable — became a test root. + // + // The counter-factual is the fixture below it: the same shape with a literal + // in its OWN initializer must still be discovered, or the fix would have + // closed the defect by discovering nothing. + const gpa = std.testing.allocator; + const src = + \\const t_mod = b.createModule(.{ .root_source_file = b.path(spec.path) }); + \\const t = b.addTest(.{ .root_module = t_mod }); + \\const exe_mod = b.createModule(.{ .root_source_file = b.path("src/runtime/main.zig") }); + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 0), roots.items.len); +} + +test "a literal root_source_file in the declaration's own initializer IS taken" { + const gpa = std.testing.allocator; + const src = + \\const t_mod = b.createModule(.{ .root_source_file = b.path("src/a/root.zig") }); + \\const t = b.addTest(.{ .root_module = t_mod }); + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 1), roots.items.len); + try std.testing.expectEqualStrings("src/a/root.zig", roots.items[0]); +} + +test "a root whose path is a loop variable is still discovered" { + // DEFECT 1, pinned. The first version read only literal `root_source_file` + // arguments, so every target built by the `test_specs` loop was invisible and + // all of `tests/` reported dead. The paths ARE literals — in the table the + // loop walks — and that is where they are read from. + const gpa = std.testing.allocator; + const src = + \\const test_specs = [_]Spec{ + \\ .{ .path = "tests/a/one.zig" }, + \\ .{ .path = "tests/a/two.zig" }, + \\}; + \\for (test_specs) |spec| { + \\ const t_mod = b.createModule(.{ .root_source_file = b.path(spec.path) }); + \\ const t = b.addTest(.{ .root_module = t_mod }); + \\} + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 2), roots.items.len); + try std.testing.expectEqualStrings("tests/a/one.zig", roots.items[0]); + try std.testing.expectEqualStrings("tests/a/two.zig", roots.items[1]); +} + +test "an inline field access whose name IS referenced is ALIVE" { + // DEFECT 2, pinned, and it is the counterpart of the third fixture above: + // same syntax, opposite outcome, decided by the reference alone. This is + // `render_graph.Graph` — `pub const Graph = @import("graph.zig").Graph;` with + // a `comptime { _ = render_graph.Graph; }` guard — whose six tests ARE + // collected, against `exact.zig`, which nothing referenced. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const Graph = @import(\"graph.zig\").Graph;\ncomptime { _ = Graph; }\n" }, + .{ "m/graph.zig", "pub const Graph = struct {};\ntest \"graph\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} + +test "a bare string-array table of roots is discovered, and only its elements" { + // The IPC targets, built from a `[_][]const u8` list walked by a loop that + // calls `addTest`. The executable's `b.path` argument sits in the same file + // and must not be taken: it builds a binary, not a test target. + const gpa = std.testing.allocator; + const src = + \\const ipc_specs = [_][]const u8{ + \\ "tests/ipc/framing.zig", + \\ "tests/ipc/shm.zig", + \\}; + \\for (ipc_specs) |p| { + \\ const t_mod = b.createModule(.{ .root_source_file = b.path(p) }); + \\ const t = b.addTest(.{ .root_module = t_mod }); + \\} + \\const exe_mod = b.createModule(.{ .root_source_file = b.path("src/runtime/main.zig") }); + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 2), roots.items.len); + try std.testing.expectEqualStrings("tests/ipc/framing.zig", roots.items[0]); + try std.testing.expectEqualStrings("tests/ipc/shm.zig", roots.items[1]); +} + +test "quoted .zig paths that feed no test target are NOT roots" { + // THE INVENTED ROOT, pinned. These three lines are the arguments of a + // `zig fmt` system command in `build.zig`, and the previous element-shaped + // matcher took them for test roots — the false-ALIVE direction, since an + // invented root admits its closure and can mask a genuinely dead file. + // + // The discriminant is the WIRING: no loop walks this list, so nothing here + // builds a test target. The fixture that preceded this one only checked that + // a `b.path(...)` call was not matched, which is a different syntax, so it + // shared the implementation's blind spot instead of testing it. + const gpa = std.testing.allocator; + const src = + \\const fmt_cmd = b.addSystemCommand(&.{ + \\ b.graph.zig_exe, + \\ "fmt", + \\ "src/core/platform/window/wayland_protocols/core.zig", + \\ "src/core/platform/window/wayland_protocols/xdg_shell.zig", + \\}); + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 0), roots.items.len); +} + +test "a table walked by a loop that builds no test target is NOT a root list" { + // The twin of the fixture above, by the pairing rule: the table has the exact + // shape of a root list and a loop DOES walk it, so only the absence of + // `addTest` in the body separates the two. Its sibling is the IPC fixture, + // which is this file with `addTest` present and must yield two roots. + const gpa = std.testing.allocator; + const src = + \\const shader_srcs = [_][]const u8{ + \\ "src/shaders/a.zig", + \\ "src/shaders/b.zig", + \\}; + \\for (shader_srcs) |p| { + \\ const c = b.addSystemCommand(&.{ "glslc", p }); + \\} + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 0), roots.items.len); +} + +test "a brace inside a string does not truncate the loop body" { + // `loopBody` brace-matches, so a `"{s}"` in a format call inside the loop — + // ordinary in `build.zig` — would close the body early under a naive scan and + // hide the `addTest` below it, turning a real root list into no roots at all. + // That is the false-DEAD direction, loud rather than green, but it would have + // condemned every root under a table that logs. + const gpa = std.testing.allocator; + const src = + \\const specs = [_][]const u8{ + \\ "tests/a.zig", + \\}; + \\for (specs) |p| { + \\ std.debug.print("building {s}}}\n", .{p}); + \\ const t = b.addTest(.{ .root_module = m }); + \\} + \\ + ; + var roots = try rootsFromBuildZig(gpa, src); + defer { + for (roots.items) |r| gpa.free(r); + roots.deinit(gpa); + } + try std.testing.expectEqual(@as(usize, 1), roots.items.len); + try std.testing.expectEqualStrings("tests/a.zig", roots.items[0]); +} + +test "a file referenced ONLY from outside the closure is DEAD" { + // FIXTURE 7 — it guards the direction the fixpoint opened, and it is the only + // one that can say green when it should say red. `outside.zig` binds AND + // references `victim.zig`, but nothing ever admits `outside.zig`, so that + // reference must not count. This is `src/etch/zig_codegen/` exactly: 37 dead + // blocks across files that reference each other. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const nothing = u8;\n" }, + .{ "m/outside.zig", "const victim = @import(\"victim.zig\");\npub const V = victim.V;\n" }, + .{ "m/victim.zig", "pub const V = u8;\ntest \"victim\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + // One dead FILE, not two: only files holding `test` blocks are counted, and + // `outside.zig` holds none. Expecting two was my own error — the report + // counts dead TESTS' homes, never every unreached file. + try std.testing.expectEqual(@as(usize, 1), r.dead.items.len); + try std.testing.expectEqualStrings("m/victim.zig", r.dead.items[0].path); + try std.testing.expectEqual(@as(usize, 0), r.live_tests); +} + +test "the same file referenced from INSIDE the closure is ALIVE" { + // The twin, by the pairing rule: same shape, discriminant inverted, verdict + // inverted. `helper.zig` is admitted, so ITS reference to `victim` counts — + // which is `render_graph/pass.zig` writing `gal.barriers.Access` for a name + // `gal/root.zig` binds and never touches. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "const helper = @import(\"helper.zig\");\ncomptime { _ = helper; }\npub const victim = @import(\"victim.zig\");\n" }, + .{ "m/helper.zig", "pub const use = victim.V;\n" }, + .{ "m/victim.zig", "pub const V = u8;\ntest \"victim\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} + +test "a name occurring only in a COMMENT is not a reference" { + // The defect that admitted all of `zig_codegen`: `src/etch/root.zig` names + // `codegen_zig` in the paragraph explaining why that subtree is DEAD, and the + // reference search read it as a use. Its twin below is the same name in code. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const orphan = @import(\"orphan.zig\");\n// orphan is held, see M1.D.5\n" }, + .{ "m/orphan.zig", "test \"dead\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), r.dead.items.len); +} + +test "a binding is not a reference to ITSELF, scanned as another file" { + // THE SELF-REFERENTIAL RULE, pinned. The cross-file search re-reads every + // live file including the one under analysis, and it passes `osrc.len` as the + // binding-line start — a sentinel meaning "no binding line to skip". So when + // the file scanned is the binding file, the exclusion window is empty and the + // binding line answers for itself: `pub const held = @import("held.zig");` + // became its own justification. + // + // It is the false-ALIVE direction and it admitted 37 blocks in the tree. The + // same-file test is now by PATH; it used to be pointer identity, which the + // production reader defeats by allocating a fresh buffer per call. + // + // The root is deliberately the ONLY live file, so the sole candidate the + // cross-file loop can find the name in is the binding file itself. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const held = @import(\"held.zig\");\n" }, + .{ "m/held.zig", "test \"held\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 1), r.dead.items.len); + try std.testing.expectEqualStrings("m/held.zig", r.dead.items[0].path); + try std.testing.expectEqual(@as(usize, 0), r.live_tests); +} + +test "a commented-out reference guard is NOT an edge" { + // `src/etch/root.zig` exactly: the paragraph explaining why `zig_codegen` is + // held shows the guard that WOULD wire it — `// _ = @import("…");` — and + // the head of that line ends in `_ =`, so the extractor took the comment for + // the code. Thirty-seven blocks were admitted by prose. + // + // Its twin is below: the same line without the `//`, which must be an edge. + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "// to wire it, write:\n// _ = @import(\"held.zig\");\npub const held = @import(\"held.zig\");\n" }, + .{ "m/held.zig", "test \"held\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 1), r.dead.items.len); + try std.testing.expectEqualStrings("m/held.zig", r.dead.items[0].path); + try std.testing.expectEqual(@as(usize, 0), r.live_tests); +} + +test "the SAME guard in code IS an edge" { + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "comptime {\n _ = @import(\"held.zig\");\n}\n" }, + .{ "m/held.zig", "test \"held\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} + +test "the same name in CODE is a reference" { + const gpa = std.testing.allocator; + var r = try runFixture(gpa, &.{ + .{ "m/root.zig", "pub const orphan = @import(\"orphan.zig\");\npub const O = orphan.V;\n" }, + .{ "m/orphan.zig", "pub const V = u8;\ntest \"live\" {}\n" }, + }, &.{"m/root.zig"}); + defer r.deinit(gpa); + defer Fixture.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), r.dead.items.len); + try std.testing.expectEqual(@as(usize, 1), r.live_tests); +} diff --git a/tools/weld_lint/main.zig b/tools/weld_lint/main.zig index e521b871..82395aab 100644 --- a/tools/weld_lint/main.zig +++ b/tools/weld_lint/main.zig @@ -3,7 +3,7 @@ //! Two subcommands wire into `build.zig`: //! - `lint [path]...` — walk the given paths (default //! `src/ bench/ tests/ tools/`, including the linter's own -//! sources so it stays exemplary) and apply rules 1–4. Exits +//! sources so it stays exemplary) and apply rules 1–6. Exits //! non-zero if any rule fires. //! - `commit-msg ` — validate the title of the commit //! message at `file` against the Conventional Commits subset @@ -21,6 +21,8 @@ const doc_comments = @import("rules/doc_comments.zig"); const c_module_isolation = @import("rules/c_module_isolation.zig"); const conventional_commit = @import("rules/conventional_commit.zig"); const no_device_dispatch_outside_gal = @import("rules/no_device_dispatch_outside_gal.zig"); +const no_float_reduce = @import("rules/no_float_reduce.zig"); +const dead_tests = @import("dead_tests.zig"); const default_lint_paths = [_][]const u8{ "src", "bench", "tests", "tools" }; @@ -46,6 +48,9 @@ pub fn main(init: std.process.Init) !u8 { if (std.mem.eql(u8, sub, "commit-msg")) { return runCommitMsg(arena, init.io, argv[2..], stdout); } + if (std.mem.eql(u8, sub, "dead-tests")) { + return runDeadTests(arena, init.io, stdout, argv[2..]); + } try stdout.print("unknown subcommand: {s}\n\n", .{sub}); try stdout.writeAll(usage_text); @@ -75,6 +80,7 @@ fn runLint(arena: std.mem.Allocator, io: std.Io, paths: []const [:0]const u8, ou try doc_comments.check(arena, file, source, &diags); try c_module_isolation.check(arena, file, source, &diags); try no_device_dispatch_outside_gal.check(arena, file, source, &diags); + try no_float_reduce.check(arena, file, source, &diags); } std.mem.sort(diag.Diagnostic, diags.items, {}, diag.Diagnostic.lessThan); @@ -102,14 +108,229 @@ fn runCommitMsg(arena: std.mem.Allocator, io: std.Io, args: []const [:0]const u8 return if (diags.items.len == 0) @as(u8, 0) else @as(u8, 1); } +/// `dead-tests` — every in-tree file holding a `test` block must belong to the +/// analysis closure of some test target, or be a declared exclusion. +/// +/// The roots are DERIVED from `build.zig` rather than kept beside it: a +/// hand-maintained list drifts the first time a target is added, and drift here +/// reads as "no dead tests", which is the failure mode that says green. +fn runDeadTests(arena: std.mem.Allocator, io: std.Io, out: *std.Io.Writer, argv_extra: []const [:0]const u8) !u8 { + const build_src = scan.readSourceZ(arena, io, "build.zig") catch { + try out.writeAll("dead-tests: cannot read build.zig\n"); + return 2; + }; + var roots = try dead_tests.rootsFromBuildZig(arena, build_src); + defer roots.deinit(arena); + + var files: std.ArrayList([]const u8) = .empty; + defer files.deinit(arena); + for (default_lint_paths) |p| try scan.collectZigFiles(arena, io, p, &files); + + // The reader closes over an arena and the io handle through a file-scope + // slot: `analyze` takes a plain function pointer so its fixtures can drive + // it without a filesystem at all. + reader_arena = arena; + reader_io = io; + var report = try dead_tests.analyze(arena, roots.items, files.items, &readForAnalysis); + defer report.deinit(arena); + + // `--list` prints the closure file by file with its test count. A delta is + // not a verdict until it is DECOMPOSED: a single number invites attribution + // by guesswork, which is how five unaccounted blocks became thirteen. + var want_list = false; + var want_per_root = false; + for (argv_extra) |a| { + if (std.mem.eql(u8, a, "--list")) want_list = true; + if (std.mem.eql(u8, a, "--per-root")) want_per_root = true; + } + if (want_list) { + for (report.closure.items) |c| { + if (c.tests > 0) try out.print("LIVE {d}\t{s}\n", .{ c.tests, c.path }); + } + } + if (want_per_root) try perRootControl(arena, out, roots.items, want_list); + + // The bilateral control. Two independent computations must land on one + // number: this closure, and the suite's own collected total. Too permissive + // overshoots it, too strict undershoots it, and only equality excludes both — + // which is why the control, and not the fixture count, is what authorises + // this guard. Twenty-odd passing fixtures once missed a defect that ONE + // NUMBER caught on its first application. + const os = @import("builtin").os.tag; + const gap = dead_tests.uncollectedOn(os); + const expected = report.live_tests - gap; + try out.print( + "dead-tests: control — closure {d} - {d} declared uncollected on {t} = {d} expected collected\n", + .{ report.live_tests, gap, os, expected }, + ); + for (dead_tests.uncollected) |u| { + if (u.only_on != null and u.only_on.? != os) continue; + try out.print(" uncollected: {s} ({d} block(s)): {s}\n", .{ u.path, u.blocks, u.reason }); + } + // LAYER ONE — THE UNCONDITIONAL CONFRONTATION, and its absence was the fourth + // instance of "a control that exists and a path bypasses" in this milestone, + // after the lint step in no workflow, the witnesses with no reader, and the + // cache save outside its own size guard. This one was inside the tool built + // against that family: the loop below runs ONLY when `--expect-collected=N` is + // passed, and neither `build.zig` nor the CI passed it, so the tool printed its + // expectation and then printed `clean` having compared it to nothing. + // + // `expected` is arithmetic on the closure and the declared gap; the number here + // is written down by a human from the suite's own reported total. Equal, not + // bounded: two computations of one quantity must AGREE, and a one-sided test + // would admit drift in the permitted direction. + const declared = dead_tests.expectedCollectedOn(os); + if (expected != declared) { + try out.print( + "dead-tests: CONSERVATION FAILED — closure gives {d} expected collected, " ++ + "`expectedCollectedOn({t})` declares {d}.\n", + .{ expected, os, declared }, + ); + try out.writeAll("The closure and the declared suite total have parted. Do NOT bump the\n" ++ + "declared number to match: that turns the control into arithmetic on itself,\n" ++ + "which is exactly how this check came to print a verdict it never computed.\n" ++ + "Run `zig build test --summary all`, read the collected total it reports, and\n" ++ + "reconcile against THAT — then decompose with `--per-root --list` if the two\n" ++ + "still disagree.\n"); + return 1; + } + try out.print( + "dead-tests: conservation OK — closure and the declared suite total agree at {d}.\n", + .{expected}, + ); + + // LAYER TWO — the suite-DERIVED confrontation. CI parses `zig build test`'s own + // summary and passes it here, which is what stops the declared number above from + // being quietly aligned to a drifted closure. + for (argv_extra) |a| { + const prefix = "--expect-collected="; + if (!std.mem.startsWith(u8, a, prefix)) continue; + const got = std.fmt.parseInt(usize, a[prefix.len..], 10) catch { + try out.print("dead-tests: --expect-collected needs a number, got '{s}'\n", .{a[prefix.len..]}); + return 2; + }; + if (got != expected) { + try out.print( + "dead-tests: CONTROL FAILED — expected {d} collected, `zig build test` reported {d}.\n", + .{ expected, got }, + ); + try out.writeAll("A gap in either direction is a finding. ABOVE the collected total means\n" ++ + "the closure admits what no binary runs; BELOW means it misses an edge and a\n" ++ + "dead file could hide behind it. Decompose with `--per-root --list` before\n" ++ + "touching either side, and declare a new term in `uncollected` only with the\n" ++ + "mechanism that explains it.\n"); + return 1; + } + try out.print("dead-tests: control OK — closure and suite agree at {d}.\n", .{expected}); + } + + // The report states the SIZE of what it judged. A tool built because probes + // rendered verdicts over unmeasured objects does not get to skip that. + try out.print( + "dead-tests: {d} roots, {d} files in closure, {d} files with tests, {d} live test blocks\n", + .{ roots.items.len, report.in_closure, report.with_tests, report.live_tests }, + ); + if (report.excluded > 0) { + try out.print("dead-tests: {d} file(s), {d} test block(s) under a DECLARED exclusion:\n", .{ report.excluded, report.excluded_tests }); + for (dead_tests.exclusions) |e| { + try out.print(" {s} (owner {s}): {s}\n", .{ e.prefix, e.owner, e.reason }); + } + } + if (report.dead.items.len == 0) { + if (report.with_tests == 0) { + try out.writeAll("dead-tests: examined ZERO files with tests — the run proves nothing\n"); + return 2; + } + try out.writeAll("dead-tests: clean.\n"); + return 0; + } + var blocks: usize = 0; + for (report.dead.items) |d| blocks += d.tests; + try out.print("dead-tests: {d} file(s) holding {d} test block(s) are outside every closure:\n", .{ report.dead.items.len, blocks }); + for (report.dead.items) |d| try out.print(" {s}: {d} test block(s)\n", .{ d.path, d.tests }); + try out.writeAll("A test block outside every closure is never ANALYSED: no elaboration, no\n" ++ + "type-check, so an API that disappears under a toolchain bump is not reported.\n" ++ + "Wire the file into a test target's closure, or add a DECLARED exclusion with\n" ++ + "its owning milestone in `tools/weld_lint/dead_tests.zig`.\n"); + return 1; +} + +/// Per-root closure control — the quantity the suite's own total is comparable to. +/// +/// WHY PER ROOT AND NOT GLOBALLY, because this is the defect the control itself +/// exposed. `analyze` carries ONE `seen` set across every root, so a file reached +/// by two test targets is counted ONCE. The suite counts it once PER BINARY: two +/// targets that both reach it compile it twice and run its tests twice. Comparing +/// the global closure against the suite total is therefore comparing a set +/// cardinality to a multiset cardinality — the same collected-versus-source unit +/// error this milestone has now made three times, in its own instrument. +/// +/// The per-root sum counts with the SAME multiplicity the suite does, so the two +/// numbers are finally the same kind of thing and their difference is a finding +/// rather than an artefact of how each was computed. +/// +/// A module import (`weld_core`, `weld_etch`) is deliberately not followed, here +/// as everywhere: Zig collects tests from the root module's own files and not +/// from the modules it imports, so following one would inflate this side against +/// a suite that never ran those tests in that binary. +fn perRootControl( + arena: std.mem.Allocator, + out: *std.Io.Writer, + roots: []const []const u8, + want_list: bool, +) !void { + var sum: usize = 0; + for (roots) |root| { + var one = try dead_tests.analyze(arena, &.{root}, &.{}, &readForAnalysis); + defer one.deinit(arena); + sum += one.live_tests; + try out.print("ROOT {d}\t{s}\n", .{ one.live_tests, root }); + if (want_list) { + for (one.closure.items) |c| { + if (c.tests > 0) try out.print(" in {s}: {d}\t{s}\n", .{ root, c.tests, c.path }); + } + } + } + try out.print( + "dead-tests: per-root closure sum = {d} test block(s) over {d} roots\n", + .{ sum, roots.len }, + ); + try out.writeAll( + "Compare against the suite's own collected total from `zig build test --summary all`.\n" ++ + "Expect a PLATFORM-DEPENDENT gap, announced before it is measured so a predicted\n" ++ + "difference is not read as a broken guard: on macOS the closure exceeds the\n" ++ + "collected total by the blocks of `src/modules/render/gal/vulkan/conv.zig`, which\n" ++ + "the Null backend never analyses; on Linux the two are equal once the declared\n" ++ + "exclusions are subtracted. This analysis is static and blind to comptime dispatch.\n", + ); +} + +var reader_arena: std.mem.Allocator = undefined; +var reader_io: std.Io = undefined; + +/// Filesystem reader handed to `analyze`; returns null for anything unreadable. +fn readForAnalysis(path: []const u8) ?[]const u8 { + return scan.readSourceZ(reader_arena, reader_io, path) catch null; +} + const usage_text = \\usage: \\ weld_lint lint [path]... \\ Walk the given paths (default `src bench tests tools`) and \\ apply rules: no_cimport, no_usingnamespace, doc_comments, - \\ c_module_isolation, no_device_dispatch_outside_gal. Exits 0 + \\ c_module_isolation, no_device_dispatch_outside_gal, + \\ no_float_reduce. Exits 0 \\ if clean, 1 if any rule fires. \\ + \\ weld_lint dead-tests [--list] [--per-root] + \\ Check that every file holding a `test` block belongs to the + \\ analysis closure of some test target, or to a declared + \\ exclusion. Exits 0 if clean, 1 if any file is outside. + \\ --list print the closure file by file with its count. + \\ --per-root print each root's own closure and their SUM — + \\ the quantity comparable to the suite's collected + \\ total, which counts a shared file once per binary. + \\ \\ weld_lint commit-msg \\ Validate the title of the commit message at against \\ the Conventional Commits subset enforced by Weld. Exits 0 diff --git a/tools/weld_lint/rules/no_float_reduce.zig b/tools/weld_lint/rules/no_float_reduce.zig new file mode 100644 index 00000000..5934d912 --- /dev/null +++ b/tools/weld_lint/rules/no_float_reduce.zig @@ -0,0 +1,236 @@ +//! Rule `no_float_reduce` — `@reduce` with an arithmetic operation is forbidden +//! unless the site declares that its lanes are integers. +//! +//! `ARCH-031` rule 3 fixes the reduction order of every float reduction on a +//! compared path, and `@reduce` does not carry it today. The language specifies +//! the order — the langref calls it "a sequential horizontal reduction" and +//! states that on floats "the operation associativity is preserved, unless the +//! float mode is set to `Optimized`" — but two Zig 0.16 backends were measured +//! at M1.1.14 to disagree on the SAME `x86_64` target: LLVM folds a 3-lane f32 +//! sum as `(p₀ + p₁) + p₂`, the self-hosted backend as `p₁ + (p₂ + p₀)`, under +//! the default float mode and under an explicit `.strict` alike. Those two +//! functions differ on 31.4% of random f32 triples, and in the determinism +//! harness it showed as the continuous state diverging at frame 1. +//! +//! So the sanctioned form is `foundation/math/reduce`, whose folds are written +//! in source and are therefore the same function under every backend, version +//! and float mode. The compiler defect is owed upstream; the rule does not wait +//! for it, because determinism that rests on someone else's release is not a +//! property the engine holds. +//! +//! WHY THIS RULE EXISTS AT ALL, rather than the fix alone: the twelve production +//! sites were replaced in one pass, and nothing would stop the thirteenth. A +//! rule written down without a check is an intention. +//! +//! WHAT IS FLAGGED. `.Add`, `.Mul`, `.Min` and `.Max` — the four operations +//! meaningful on floats. `.And`, `.Or` and `.Xor` are boolean and integer only, +//! are order-free, and are never flagged; the tree's `@reduce(.And, a <= b)` +//! predicates are legitimate and stay. +//! +//! THE ESCAPE, and why it is a marker rather than a path allowlist. Integer +//! lanes make `.Add`/`.Mul`/`.Min`/`.Max` exact under any order, so those sites +//! are legitimate — but the linter is a tokenizer and cannot see an element +//! type. A path allowlist would grant the exemption to a whole file, including +//! the float reduction someone adds to it next year. `WELD_INTEGER_LANES` on the +//! statement's own line or the line above grants it to ONE site, in the reader's +//! view, and reads as the claim it is: *these lanes are integers.* +//! +//! This rule is deliberately not scoped to `src/`: `bench/` and the test files +//! feed the same measurements, and a float reduction in a bench is a reason for +//! a bench figure that cannot be reproduced. + +const std = @import("std"); +const diag = @import("../diagnostic.zig"); + +const name = "no_float_reduce"; + +/// The marker a site uses to declare integer lanes, on the `@reduce` line or the +/// line immediately above it. +const integer_marker = "WELD_INTEGER_LANES"; + +/// The reduction operations that are meaningful on floats, hence order- or +/// NaN-sensitive. Kept as a table so a reader sees the whole flagged set at once. +const arithmetic_ops = [_][]const u8{ "Add", "Mul", "Min", "Max" }; + +/// Hook called by `main.runLint` once per `.zig` file. +/// +/// Tokenizes and looks for the shape `@reduce` `(` `.` IDENT, flagging the site +/// when IDENT is one of `arithmetic_ops` and neither the site's own line nor the +/// one above carries `WELD_INTEGER_LANES`. Tokenizing rather than substring +/// matching is what keeps the rule off its own prose: this file names +/// `@reduce(.Add` in a doc comment above, and a doc comment is one token. +pub fn check( + arena: std.mem.Allocator, + file: []const u8, + source: [:0]const u8, + out: *std.ArrayList(diag.Diagnostic), +) !void { + var tokenizer = std.zig.Tokenizer.init(source); + + // The three-token window `@reduce` `(` `.` that must precede the operation. + var saw_reduce = false; + var saw_lparen = false; + var saw_period = false; + + while (true) { + const tok = tokenizer.next(); + if (tok.tag == .eof) break; + const slice = source[tok.loc.start..tok.loc.end]; + + if (saw_reduce and saw_lparen and saw_period and tok.tag == .identifier) { + if (isArithmetic(slice) and !hasIntegerMarker(source, tok.loc.start)) { + const pos = diag.lineColFromOffset(source, tok.loc.start); + try out.append(arena, .{ + .file = file, + .line = pos.line, + .col = pos.col, + .rule = name, + .message = "`@reduce` with an arithmetic operation delegates the reduction order to the backend, which `ARCH-031` rule 3 forbids on a compared path — use `foundation/math/reduce` (`foldAdd`/`foldMul`/`foldMin`/`foldMax`), or declare integer lanes with a `WELD_INTEGER_LANES` comment on this line or the line above", + }); + } + } + + switch (tok.tag) { + .builtin => { + saw_reduce = std.mem.eql(u8, slice, "@reduce"); + saw_lparen = false; + saw_period = false; + }, + .l_paren => { + saw_lparen = saw_reduce; + saw_period = false; + }, + .period => { + saw_period = saw_reduce and saw_lparen; + }, + else => { + saw_reduce = false; + saw_lparen = false; + saw_period = false; + }, + } + } +} + +/// Whether `op` names one of the float-meaningful reduction operations. +fn isArithmetic(op: []const u8) bool { + for (arithmetic_ops) |candidate| { + if (std.mem.eql(u8, op, candidate)) return true; + } + return false; +} + +/// Whether the site at `offset` is exempted: the marker sits on its own line, or +/// on the line immediately above WHEN THAT LINE IS A PURE COMMENT. +/// +/// The line above is admitted because the claim usually deserves a sentence, and +/// a justification pushed onto its own line should not have to repeat the marker +/// on the statement. The pure-comment restriction is what keeps that from +/// leaking: without it a trailing `// WELD_INTEGER_LANES` on one statement +/// silently exempts the NEXT one, which is how two adjacent reductions come to +/// share a single claim that was only ever made about the first. Found by a +/// failing test whose own premise was wrong — it expected per-site scoping from +/// two adjacent lines, and the rule as first written did not have it. +fn hasIntegerMarker(source: []const u8, offset: usize) bool { + const line_start = if (std.mem.lastIndexOfScalar(u8, source[0..offset], '\n')) |i| i + 1 else 0; + const line_end = std.mem.indexOfScalarPos(u8, source, offset, '\n') orelse source.len; + if (std.mem.indexOf(u8, source[line_start..line_end], integer_marker) != null) return true; + + if (line_start == 0) return false; + const prev_end = line_start - 1; + const prev_start = if (std.mem.lastIndexOfScalar(u8, source[0..prev_end], '\n')) |i| i + 1 else 0; + const prev = std.mem.trim(u8, source[prev_start..prev_end], " \t\r"); + if (!std.mem.startsWith(u8, prev, "//")) return false; + return std.mem.indexOf(u8, prev, integer_marker) != null; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Runs the rule over `source` and returns how many diagnostics it produced. +fn countOn(source: [:0]const u8) !usize { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var diags: std.ArrayList(diag.Diagnostic) = .empty; + try check(arena_state.allocator(), "probe.zig", source, &diags); + defer diags.deinit(arena_state.allocator()); + return diags.items.len; +} + +test "the four float-meaningful operations are flagged" { + try std.testing.expectEqual(@as(usize, 1), try countOn("const x = @reduce(.Add, v);\n")); + try std.testing.expectEqual(@as(usize, 1), try countOn("const x = @reduce(.Mul, v);\n")); + try std.testing.expectEqual(@as(usize, 1), try countOn("const x = @reduce(.Min, v);\n")); + try std.testing.expectEqual(@as(usize, 1), try countOn("const x = @reduce(.Max, v);\n")); +} + +test "the order-free boolean operations are not flagged" { + try std.testing.expectEqual(@as(usize, 0), try countOn("const x = @reduce(.And, a <= b);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn("const x = @reduce(.Or, a > b);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn("const x = @reduce(.Xor, a);\n")); +} + +test "the integer-lanes marker exempts one site, on its line or the line above" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + "const x = @reduce(.Add, v); // WELD_INTEGER_LANES u32 lanes\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + "// WELD_INTEGER_LANES u32 lanes\nconst x = @reduce(.Add, v);\n", + )); + // TWO lines above is out of reach: the exemption is per-site, and a marker + // that drifts arbitrarily far from its statement stops being a claim about it. + try std.testing.expectEqual(@as(usize, 1), try countOn( + "// WELD_INTEGER_LANES\n\nconst x = @reduce(.Add, v);\n", + )); +} + +test "the marker exempts only the site it sits on" { + // Non-vacuity for the test above: with two sites and one marker, exactly one + // must survive — a rule that exempted the whole FILE would report zero here + // and would still pass every single-site test written above. + try std.testing.expectEqual(@as(usize, 1), try countOn( + \\const a = @reduce(.Add, v); // WELD_INTEGER_LANES + \\const b = @reduce(.Add, w); + \\ + )); +} + +test "a trailing marker does not leak onto the statement below it" { + // The case that made the test above fail when the line-above allowance was + // unconditional: line 1's marker is a trailing comment on a STATEMENT line, + // so it is a claim about line 1 and about nothing else. Only a line that is + // wholly a comment may speak for the statement beneath it. + try std.testing.expectEqual(@as(usize, 1), try countOn( + \\const a = @reduce(.Add, v); // WELD_INTEGER_LANES + \\const b = @reduce(.Add, w); + \\ + )); + // And the legitimate form still works, so the restriction did not close the + // door it exists to keep open. + try std.testing.expectEqual(@as(usize, 0), try countOn( + \\// WELD_INTEGER_LANES: `u32` lanes, exact under any order. + \\const b = @reduce(.Add, w); + \\ + )); +} + +test "the rule is written on tokens, so prose naming the builtin is not a site" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + "/// Never write `@reduce(.Add, v)` on a float path.\nconst x = 1;\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + "// @reduce(.Max, v) is what this replaces.\nconst x = 1;\n", + )); +} + +test "an unrelated builtin taking an enum literal is not a site" { + try std.testing.expectEqual(@as(usize, 0), try countOn("const x = @as(.Add, v);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn("const x = foo(.Max, v);\n")); +} + +test "several sites on one line are each reported" { + try std.testing.expectEqual(@as(usize, 2), try countOn( + "const x = @reduce(.Add, v) + @reduce(.Max, w);\n", + )); +} diff --git a/tools/weld_lint/tests.zig b/tools/weld_lint/tests.zig new file mode 100644 index 00000000..2f52f782 --- /dev/null +++ b/tools/weld_lint/tests.zig @@ -0,0 +1,40 @@ +//! Test root for the linter's own unit tests. +//! +//! A `test` block runs only if the compiler ANALYSES the file holding it, and +//! Zig analyses declarations lazily. Two forms were tried at M1.1.14 and only +//! one works — measured, by appending a deliberately failing test to a rule and +//! watching for red: +//! +//! - `addTest` rooted at `main.zig`, which reaches the rules through plain +//! `const` imports: ran NOTHING and reported success. +//! - This file as the root with `pub const` re-exports: also ran nothing — +//! `zig test` reported "All 0 tests passed" over nine re-exported files, one +//! of them holding eight test blocks. A `pub` declaration nobody references +//! is still not analysed. +//! - The `comptime` block below, which REFERENCES each import: runs them. +//! +//! `usingnamespace` is forbidden (`engine-zig-conventions.md`), and it would not +//! have helped either — the question is analysis, not namespacing. +//! +//! WHAT THIS LAYER PROVES, and what it does not. It proves each rule's LOGIC: +//! how many diagnostics a source yields, where an escape hatch reaches, whether +//! prose naming a construct is mistaken for a use of it. It cannot prove a rule +//! is WIRED into `runLint` — a rule deleted from `main.zig` passes every test +//! here. That is what the fixture corpus under `tests/lint/` is for: it runs the +//! real binary and reads its exit code. Neither layer substitutes for the other, +//! and a rule wants both. + +comptime { + // Rules. + _ = @import("rules/no_cimport.zig"); + _ = @import("rules/no_usingnamespace.zig"); + _ = @import("rules/doc_comments.zig"); + _ = @import("rules/c_module_isolation.zig"); + _ = @import("rules/no_device_dispatch_outside_gal.zig"); + _ = @import("rules/no_float_reduce.zig"); + _ = @import("rules/conventional_commit.zig"); + // Shared machinery. + _ = @import("dead_tests.zig"); + _ = @import("scan.zig"); + _ = @import("diagnostic.zig"); +}