feat(aarch64): complete the scalar float surface — rounding, FP memory, i64 converts, GUARDED i64 truncations (#851) - #898
feat(aarch64): complete the scalar float surface — rounding, FP memory, i64 converts, GUARDED i64 truncations (#851)#898avrabe wants to merge 8 commits into
Conversation
Coordinator corrections applied (L1 / #890) — and one of them was a real defect here1. The gate I added could pass while its own oracle failed. Not hypothetical — reproduced against a stub that prints
Without 2. Also re-ran the unicorn-only path (what Adjacent finding, deliberately NOT fixed here (lane containment)Auditing every
They are materially safer than mine was — each one's last command is a Leaving them to L1/#890 rather than touching another lane's steps in a file we both edit. Flagging so the decision is explicit, not an oversight. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
) v0.54 L2 step 1 — the four encoder families the VCR-SEL-005 third-backend parity oracle names in its ROUNDING / FP_MEM / I64_TO_FP decline reasons: * `FRINT{P,M,Z,N}` s/d — round-to-integral with the mode pinned in the OPCODE (not FPCR.RMode), so the lowering cannot depend on ambient embedder state. FRINTN is round-to-nearest-TIES-TO-EVEN, which is what WASM §4.3.3 `fnearest` requires (FRINTA — ties-away — would be wrong). * `LDR/STR (SIMD&FP, unsigned offset)` s/d — the linear-memory FP access forms, same size/opc/scaled-imm12 shape as the GP family with the V-register bit set. * `SCVTF`/`UCVTF` x-source forms — the i64→float converts, exactly the already-shipped w-forms with `sf` set (mirroring the sdiv/sdiv64 pair). Every encoding is pinned to `clang -target aarch64-linux-gnu` ground truth in three new tests (assemble the mnemonic, objdump, read the 32-bit word), plus the two structural relations (d-form = s-form | ftype bit; x-form = w-form | sf) so a future edit of one half cannot silently drift from the other. Encoder only — no selector arm consumes these yet, so output bytes are unchanged for every currently-compiling module. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
v0.54 L2 step 2 — two of the four classes the VCR-SEL-005 third-backend
oracle enumerated:
* ROUNDING: `f{32,64}.{ceil,floor,trunc,nearest}` each lower to ONE
mode-pinned `FRINT{P,M,Z,N}`. TOTAL ops — no domain guard, and a guard
would be a defect (it would trap where WASM returns a value).
* I64_TO_FP: `f{32,64}.convert_i64_{s,u}` lower to the `x`-form
SCVTF/UCVTF. Also total.
Parity-gate entries flipped Err→Ok in the SAME commit (8 rounding + 4
converts) and the now-unused ROUNDING / I64_TO_FP reason constants DELETED —
a gap claim must not outlive the gap. No wildcard arm, nothing moved to
StructurallyExcluded. The selector's own stale claim
(`rounding_and_i64_float_converts_are_loud_declined`) is narrowed to the one
class still open (`trapping_i64_truncations_are_loud_declined`).
DECODER (the end-to-end half): `f32.{ceil,floor,trunc,nearest}` were dropped
at `_ => None`, so a real module using them loud-SKIPPED the whole function
before the selector ever saw it. Un-dropped, exactly as #538 m4 did for
f32.min/max — and with the same consequence for ARM32.
ARM32 now LOUD-DECLINES the four (previously unreachable, so this exposes no
regression): its `ArmOp::F32{Ceil,Floor,Trunc,Nearest}` pseudo-op is an
FPSCR-RMode + `VCVT.S32.F32` + `VCVT.F32.S32` ROUND-TRIP THROUGH i32, and
VCVT SATURATES — `ceil(1e30)` would give 2147483648.0, `ceil(±inf)` a finite
bound, `ceil(NaN)` 0.0, where WASM §4.3.3 returns 1e30 / ±inf / NaN. That is
the #709 more-total-than-WASM class; declining keeps it latent instead of
shipping it. The `f32_operations_test` cases that pinned the wrong lowering
as Ok are replaced by the decline assertion.
The #554 honesty fixture used `f64.floor` as its "deliberately declined float
op" — a claim this commit invalidates. Repointed at a value-carrying
(f32-result) `block`: still fully DECODED, still declined by the aarch64
SELECTOR, and the assertion is STRENGTHENED (stderr must come from the
selector AND name the reason, not merely contain "unsupported").
EXECUTION-VERIFIED on this arm64 host, MAP_JIT native call vs wasmtime,
bit-exact over 62 checks: the ties-to-even halfway table (0.5→0, 1.5→2,
2.5→2, 3.5→4 and negatives) CONFIRMS FRINTN is roundTiesToEven (FRINTA would
fail 0.5 and 2.5), plus ±0/±inf/NaN/1e30 passthrough and the i64 convert
rounding ties (2^53±1, 2^62, ±2^63, 2^64−1).
Gates: cargo test --workspace exit 0 (130 suites), clippy -D warnings exit 0,
fmt --check clean, frozen anchors 10/10 (no ARM golden moved — the four
rounding ops were undecodable, so no frozen fixture contains one).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…oad/store (#851) v0.54 L2 step 3 — the third of the four classes the VCR-SEL-005 third-backend oracle enumerated. `f{32,64}.{load,store}` lower to the SIMD&FP unsigned-offset `ldr/str s|d` against the `x28` linear-memory base. BOUNDS, NOT AN AFTERTHOUGHT: the new `fload`/`fstore` closures reuse the SAME `form_ea` the integer accesses use, so an FP access inherits v0.52's #865 software bounds check verbatim — `uxtw(addr) + offset + size <= limit` proven (or `brk`) BEFORE the dereference, with the width folded into the compile-time constant K. That width matters: f64 gets K = limit − 8, so address 65532 is IN bounds for `f32.load` and OUT for `f64.load` on a one-page memory, and the oracle exercises exactly that split. Parity-gate entries flipped Err→Ok in the SAME commit (f32.load, f32.store, f64.load, f64.store) and the FP_MEM reason constant DELETED. No wildcard arm. Four selector tests pin the shape: the full bounds-checked f32.load sequence, the f64 width-aware K, scaled-imm12 offset folding (offset/4 for s, offset/8 for d), and the type-confusion guard (an i32 fed to `f32.store` ERRORS rather than storing the wrong register file). EXECUTION-VERIFIED vs wasmtime under unicorn (x28 = linear-memory base): 128/128 bit-exact over the {f32,f64} × {0, 4, 8, 100, 65532, 65535, 65536, 0xFFFFFFFF} × {1.5, ±0.0, ±inf, NaN, 3.14159, 1e30} store→load round-trip matrix — 56 of them OOB cases where synth traps exactly where wasmtime traps, and the in-bounds cases bit-exact including NaN payload and the sign of −0.0. Gates: aarch64 lib 111/111, parity oracle 8/8, clippy -D warnings exit 0, fmt clean, frozen anchors 10/10 (no ARM golden touched). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…ations (#851) v0.54 L2 step 4 — the soundness-critical class, and the last of the four the VCR-SEL-005 third-backend oracle enumerated. `i64.trunc_f{32,64}_{s,u}` now lower to the `x`-form FCVTZ{S,U} behind the #709 domain guard. WHY A GUARD AND NOT A BARE CONVERT: A64 FCVTZS/FCVTZU are MORE TOTAL than WASM. On NaN they return 0; out of range they SATURATE to INT64_MIN/MAX (or 0/UINT64_MAX). WASM §4.3.3 requires a TRAP for every one of those inputs. Emitting the bare instruction is not an approximation, it is a silent miscompile — the #633/#666/#709/#665/#642 class this project has shipped before. `trunc_guarded` gains a `dst64` parameter so the i64 forms reuse the proven shape verbatim (ordered `b.mi` hi check — FALSE for NaN, so NaN falls into the first `brk` — then the lo check, then the convert). THE BOUNDARY CONSTANTS, each justified rather than copied: * f32->s64 / f64->s64 lo = -2^63, INCLUSIVE (`b.ge`). -2^63 is exactly representable in both formats and truncates to a LEGAL INT64_MIN; a strict bound would trap it. * f64->s64 differs from the i32/f64 row, which needs the STRICT -(2^31)-1 because -2147483648.5 exists. At 2^63 the f64 ULP is 2048, so NO f64 lies in (-2^63-1, -2^63) — the inclusive bound is both exact and necessary. * u64 lo = -1.0, STRICT (`b.gt`): trunc_u(-0.5) = 0 is legal. * hi = 2^63 / 2^64, exclusive — NOT the 2^32 the i32 forms use, which would trap every legal value above 4294967295. Parity-gate entries flipped Err->Ok in the SAME commit (4) and the TRAP_TRUNC_I64 reason constant DELETED. The extended-surface gate now carries only SIMD / multi-memory / call_indirect declines: the SCALAR FLOAT SURFACE IS COMPLETE. The selector's own decline claim (`trapping_i64_truncations_are_loud_declined`) is replaced by three tests that pin the guard instead: two `brk`s per form with the convert strictly AFTER both, the inclusive-vs-strict signed lower bound (both formats), and the 2^64-not-2^32 unsigned upper bound. Execution evidence + CI wiring land in the next commit. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…n surface (#851) v0.54 L2 step 5 — the execution evidence for all four closed classes, wired into CI in the SAME commit (#890: v0.53 found three oracles that existed and ran nowhere). `aarch64_float_completion_851_differential.py` — 662 checks, 142 of them TRAP cases, bit-exact against wasmtime under TWO independent oracles: unicorn (UC_ARCH_ARM64, with x28 = linear-memory base) and, on an arm64 host, a real native call per case in a FORKED CHILD so an expected `brk #0` SIGTRAP is observed by the parent and a surprise one is survivable. The soundness-critical half is a full BOUNDARY TABLE, not spot checks. For each trapping i64 truncation: ±2^63 and 2^64 exactly, the nearest representable float strictly INSIDE each bound and the nearest strictly OUTSIDE it (stepped on the true f32 grid via an IEEE total-order key — plain `math.nextafter` walks the far finer f64 grid), ±0, ±inf and NaN. The static expect-trap column is itself validated against wasmtime first, so the table cannot drift vacuous. Rounding is compared BIT-EXACTLY over a halfway table (0.5, 1.5, 2.5, 3.5 and negatives, up to the largest representable halfway value in each format). That is what makes "FRINTN is ties-to-EVEN" a CHECK rather than a claim: a ties-away FRINTA lowering returns 1 and 3 for 0.5 and 2.5 and fails here. ±inf / NaN / 1e30 catch the other classic wrong lowering, a round-trip through an integer. The i64->float converts pin round-to-nearest-even at the 2^24 / 2^53 onsets; the FP-memory cases cover the width-aware bounds split (65532 in-bounds for f32, out for f64) with NaN payload and -0.0 sign preserved through the round-trip. PROVEN NON-VACUOUS BY MUTATION, both directions of wrong: * replace one guarded convert with a bare saturating one -> 18 failures (`A64=0x7fffffffffffffff wasmtime=TRAP`) — the silent miscompile; * make the signed lower bound off-by-one strict -> 4 failures (`A64=TRAP wasmtime=0x8000000000000000`) — the over-trap. The harness also carries its own floors (>=300 checks, >=40 trap cases, every class non-empty) and hard-fails if the compile SKIPS any function. CI: `set -o pipefail`, `tee`, and a grep + numeric assertion on the check and trap counts, so an oracle that silently stops exercising anything goes red instead of green. The decline-matrix honesty oracle is repointed: rounding, f32/f64 load/store, i64->float converts and the trapping i64 truncations are no longer declines, so keeping them there would be a stale claim. It now pins the STRUCTURAL declines end-to-end — call_indirect, br_table, writing a param local, globals, memory.fill, a value-carrying block, SIMD — 7/7 loud. (The param-write case needed a body that also READS the param: a write-only local 0 is indistinguishable from a fresh non-param local and lowers correctly as one.) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…loat completion (#851) v0.54 L2 step 6 — close the claims. gale's acceptance matrix (`scripts/repro/aarch64_matrix.sh`) exercises the 16 newly-lowering ops: f32/f64 rounding and the i64->float converts through reinterpret wrappers so the compare is BIT-EXACT (±0 sign and NaN passthrough included), and the four trapping i64 truncations through a new `trap64` helper that normalizes both sides (the JIT runner's signal handler prints TRAP; wasmtime exits non-zero with empty stdout) so a trap DISAGREEMENT is reported as a miscompile instead of vanishing into an unnoticed empty string. 45 -> 61 ops accepted, 119 -> 355 native checks, DECLINED FRONTIER NOW EMPTY. Red-first: the same bare-saturating-convert mutation makes the matrix exit 1 with 7 named `s=<saturated>,w=TRAP` miscompiles, so the new trap helper is not vacuous shell. f32/f64 LOAD/STORE are deliberately NOT in the matrix and the script says why: they need `x28` = linear-memory base on entry, which the bare JIT runner cannot establish. They are execution-verified under unicorn in the differential. `scripts/templates/feature_matrix.md.tmpl` (the TEMPLATE — the generated docs/status/FEATURE_MATRIX.md is regenerated by the coordinator, #805): the aarch64 row's Declines list no longer names rounding, f32/f64 load/store, i64->float converts or trapping i64-target truncations — those four claims are now false. The capability text states the scalar float surface is COMPLETE and names the evidence; the memory clause records that f32/f64 accesses are bounds-checked by the same path as the integer ones. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…851) Mechanical `python3 scripts/claim_check.py claims.yaml --emit-status` output — the claim-check gate goes red otherwise (the generated files must match their template + re-derived counts at every commit). aarch64_selector_ops: 161 -> 181 (the 20 newly-lowered WasmOp variants: 8 rounding + 4 i64->float converts + 4 trapping i64 truncations + 4 FP load/store) COORDINATOR (#805): re-run `--emit-status` ONCE after fan-in — this file will conflict with any other lane that also moves a counted claim. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…#890) Coordinator correction from lane L1, and it was a REAL defect in the step I shipped two commits ago — not a hypothetical. `set -o pipefail` ALONE does not fail the step. Without `-e`, the step's exit status is that of the LAST command, so the failing pipeline's non-zero was discarded and the trailing `[ ... ] || exit 1` count checks (which pass on a FAILING run, because a failing oracle still prints its full case counts) decided the verdict. Reproduced locally against a stub that prints `RESULT: FAIL (1)` and exits 1: old shape (bare pipefail) -> exit 0 GREEN while the oracle FAILED new shape (set -euo pipefail) -> exit 1 correctly RED Fixed by making the shell flags explicit (`set -euo pipefail`, not relying on the runner's defaults) AND by deriving the verdict from the script's OWN summary line — `grep -q '^RESULT: PASS'` — instead of from exit 0 alone. The non-collapsed count assertions stay as the anti-vacuity floor and are now bare tests, so `-e` aborts on them too. Re-verified green against the real oracle (662 checks, 142 trap cases). Also adds the `# ci-status: wired` header L1's new oracle-wiring gate requires on `scripts/repro/*.py` (docstring left intact — it is still the module docstring with a comment above it). Unrelated polish caught while verifying the ARM32 decline on all three target paths (m4f, m4f --relocatable, m7dp --relocatable — all loud, RV32 too): a line-continuation in the new diagnostic split "WASM-correct" as "WASM- correct" in the user-facing message. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
193958c to
52a8d87
Compare
v0.54 lane L2. Closes the four classes v0.53's VCR-SEL-005 third-backend
op-parity oracle enumerated as
Err(reason)ina64_extended_surface. Everygate entry was flipped
Err→Ok(())in the same commit as the loweringand each now-unused reason constant deleted — no wildcard arm, nothing moved to
StructurallyExcluded. After this the aarch64 extended-surface gate carries onlySIMD / multi-memory /
call_indirect: the scalar float surface is complete.ROUNDINGFRINT{P,M,Z,N}s/dFP_MEMLDR/STRs/d, bounds-checkedI64_TO_FPSCVTF/UCVTFx-formsTRAP_TRUNC_I64FCVTZ{S,U}The soundness-critical half
A64
FCVTZS/FCVTZUare more total than WASM: NaN → 0, out of range →saturate to
INT64_MIN/MAX, where §4.3.3 requires a trap. So the trappingi64 truncations go behind the #709 two-sided guard, and the boundary constants
are justified rather than copied from the i32 rows:
b.ge) in both formats: −2^63 isexactly representable and truncates to a legal
INT64_MIN; a strict boundwould trap it. This differs from the i32/f64 row's strict −(2^31)−1, and
for a concrete reason: the f64 ULP at 2^63 is 2048, so no f64 exists in
(−2^63−1, −2^63), whereas −2147483648.5 does exist near 2^31.
b.gt):trunc_u(-0.5) = 0is legal.would trap every legal value above 4294967295.
FRINTNbeing round-to-nearest-ties-to-even (what WASMnearestrequires,vs
FRINTA's ties-away) is checked, not assumed: the halfway table0.5→0, 1.5→2, 2.5→2, 3.5→4 and negatives fails under
FRINTA.FP loads/stores go through the same
form_eaas the integer accesses, sothey inherit v0.52's #865 software bounds check verbatim, width-aware: address
65532 is in-bounds for
f32.loadand out forf64.loadon a one-page memory.Evidence
scripts/repro/aarch64_float_completion_851_differential.py— 662 checks,142 of them trap cases, bit-exact vs wasmtime under two oracles: unicorn
(x28 = linmem base) and native arm64 (each call in a forked child so an expected
SIGTRAPis observed). NaN compared NaN-aware per §4.3.3; values, traps and ±0signs bit-for-bit. The static expect-trap column is validated against wasmtime
first, so the table cannot drift vacuous.
Proven non-vacuous by mutation, both directions of wrong:
A64=0x7fffffffffffffff wasmtime=TRAP)A64=TRAP wasmtime=0x8000000000000000)CI-wired in the same commit with
set -o pipefail+ a numeric assertion on thecheck and trap counts (#890 — v0.53 found three oracles that ran nowhere).
gale's matrix: 45 → 61 ops accepted, 119 → 355 native checks, declined
frontier now EMPTY, including on-silicon trap agreement (its new
trap64helper is itself red-first verified: the same mutation makes it exit 1 with 7
named miscompiles).
Two things I had to move, not delete
f32.{ceil,floor,trunc,nearest}were dropped at the decoder, so the wholefunction loud-SKIPPED before any backend saw it. Un-dropped — which means
ARM32 must now LOUD-DECLINE them: its legacy pseudo-op is an FPSCR-RMode +
VCVT.S32.F32+VCVT.F32.S32round-trip through i32, and VCVTsaturates, so
ceil(1e30)→ 2147483648.0,ceil(±inf)→ a finite bound,ceil(NaN)→ 0.0. That thumb-2 f32: i32.trunc_f32_s/u silently saturate instead of trapping on NaN/Inf/out-of-range (bare VCVT, no guard) — WASM Core §4.3.3 soundness bug #709-class miscompile was latent only because the opwas undecodable; declining keeps it latent instead of shipping it. Exactly the
move Feature challenge: add an aarch64 (host-native) backend so output runs and debugs natively on arm64 dev machines #538 m4 made for
f32.min/max. Follow-up: a realVRINT.F32lowering.f64.flooras its "deliberately declinedfloat op" — a claim this PR invalidates. Repointed at a value-carrying
(f32-result)
block(still decoded, still declined at the selector) and theassertion strengthened: stderr must come from the aarch64 selector and
name the reason, not merely contain "unsupported". The decline-matrix oracle
likewise now pins the structural declines (7/7 loud).
Gates (real exit codes, no
| tail && echo OK)cargo test --workspace0 ·clippy --workspace --all-targets -D warnings0 ·fmt --check0 · frozen anchors 10/10 (no ARM golden moved — the fourrounding ops were undecodable, so no frozen fixture contains one) ·
claim_check34/34 · all 12 pre-existing aarch64 oracles still green.Left declined, honestly
call_indirect, import calls, value-carrying blocks/loops,br_table, writinga PARAM local, globals,
memory.copy/fill, v128/SIMD, multi-memory,>8args, float-result callees — all still loud, all still enumerated by the same
oracle.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L