Skip to content

feat(aarch64): complete the scalar float surface — rounding, FP memory, i64 converts, GUARDED i64 truncations (#851) - #898

Open
avrabe wants to merge 8 commits into
mainfrom
feat/v054-l2-aarch64-float
Open

feat(aarch64): complete the scalar float surface — rounding, FP memory, i64 converts, GUARDED i64 truncations (#851)#898
avrabe wants to merge 8 commits into
mainfrom
feat/v054-l2-aarch64-float

Conversation

@avrabe

@avrabe avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

v0.54 lane L2. Closes the four classes v0.53's VCR-SEL-005 third-backend
op-parity oracle enumerated as Err(reason) in a64_extended_surface. Every
gate entry was flipped ErrOk(()) in the same commit as the lowering
and each now-unused reason constant deleted — no wildcard arm, nothing moved to
StructurallyExcluded. After this the aarch64 extended-surface gate carries only
SIMD / multi-memory / call_indirect: the scalar float surface is complete.

class lowering gate entries flipped
ROUNDING FRINT{P,M,Z,N} s/d 8
FP_MEM SIMD&FP LDR/STR s/d, bounds-checked 4
I64_TO_FP SCVTF/UCVTF x-forms 4
TRAP_TRUNC_I64 domain-guarded x-form FCVTZ{S,U} 4

The soundness-critical half

A64 FCVTZS/FCVTZU are more total than WASM: NaN → 0, out of range →
saturate to INT64_MIN/MAX, where §4.3.3 requires a trap. So the trapping
i64 truncations go behind the #709 two-sided guard, and the boundary constants
are justified rather than copied from the i32 rows:

  • signed lower bound = inclusive −2^63 (b.ge) in both formats: −2^63 is
    exactly representable and truncates to a legal INT64_MIN; a strict bound
    would 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.
  • unsigned lower bound = strict −1.0 (b.gt): trunc_u(-0.5) = 0 is legal.
  • upper bounds 2^63 / 2^64 exclusive — not the 2^32 the i32 forms use, which
    would trap every legal value above 4294967295.

FRINTN being round-to-nearest-ties-to-even (what WASM nearest requires,
vs FRINTA's ties-away) is checked, not assumed: the halfway table
0.5→0, 1.5→2, 2.5→2, 3.5→4 and negatives fails under FRINTA.

FP loads/stores go through the same form_ea as the integer accesses, so
they inherit v0.52's #865 software bounds check verbatim, width-aware: address
65532 is in-bounds for f32.load and out for f64.load on a one-page memory.

Evidence

scripts/repro/aarch64_float_completion_851_differential.py662 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
SIGTRAP is observed). NaN compared NaN-aware per §4.3.3; values, traps and ±0
signs 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:

mutation result
one guarded convert → bare saturating 18 failures (A64=0x7fffffffffffffff wasmtime=TRAP)
signed lower bound → off-by-one strict 4 failures (A64=TRAP wasmtime=0x8000000000000000)

CI-wired in the same commit with set -o pipefail + a numeric assertion on the
check 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 trap64
helper is itself red-first verified: the same mutation makes it exit 1 with 7
named miscompiles).

Two things I had to move, not delete

Gates (real exit codes, no | tail && echo OK)

cargo test --workspace 0 · clippy --workspace --all-targets -D warnings 0 ·
fmt --check 0 · frozen anchors 10/10 (no ARM golden moved — the four
rounding ops were undecodable, so no frozen fixture contains one) ·
claim_check 34/34 · all 12 pre-existing aarch64 oracles still green.

Coordinator (#805): artifacts/status.json +
docs/status/FEATURE_MATRIX.md were regenerated mechanically
(aarch64_selector_ops 161 → 181) so claim-check is green here. Please re-run
--emit-status once after fan-in; those two files will conflict with any
other lane that moves a counted claim. The template
(scripts/templates/feature_matrix.md.tmpl) is edited as a source file — its
aarch64 Declines list no longer names the four now-false claims.

Left declined, honestly

call_indirect, import calls, value-carrying blocks/loops, br_table, writing
a PARAM local, globals, memory.copy/fill, v128/SIMD, multi-memory, >8
args, float-result callees — all still loud, all still enumerated by the same
oracle.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

@avrabe

avrabe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Coordinator corrections applied (L1 / #890) — and one of them was a real defect here

1. The gate I added could pass while its own oracle failed. Not hypothetical — reproduced against a stub that prints RESULT: FAIL (1) and exits 1:

step shape exit verdict
set -o pipefail (what I shipped in 7cb8771) 0 🟢 GREEN while the oracle FAILED
set -euo pipefail + grep -q '^RESULT: PASS' 1 🔴 correctly RED

Without -e the step's status is that of the last command, and my trailing count assertions pass on a failing run (a failing oracle still prints its full case counts). Fixed in 193958c: explicit set -euo pipefail (not relying on the runner's default flags) plus the verdict taken from the script's own summary line rather than exit 0. Re-verified green against the real oracle — 662 checks, 142 trap cases.

2. # ci-status: wired added to scripts/repro/aarch64_float_completion_851_differential.py (line 2; module docstring intact).

Also re-ran the unicorn-only path (what aarch64-oracle executes on ubuntu-latest, where the native leg is skipped): 662/662, 142 trap cases — well clear of the ≥300 / ≥40 floors.


Adjacent finding, deliberately NOT fixed here (lane containment)

Auditing every run: block in ci.yml for the same pattern turns up three more bare-pipefail steps, all in trap-semantics-oracle:

  • Run gpio-thin size + mmio execution oracle (#846/#879, cortex-m3)
  • Run VFP spill execution oracle (#881, cortex-m7dp)
  • Run VCR-DEC-001 join-allocator execution differential (#242, thumb2)

They are materially safer than mine was — each one's last command is a grep on a success marker, so a missing marker does redden the step. The residual hole is narrower: an oracle that prints its success marker and then fails a later sub-check and exits non-zero would still go green.

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

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.77301% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-core/src/wasm_decoder.rs 0.00% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

avrabe and others added 8 commits July 30, 2026 19:41
)

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
@avrabe
avrabe force-pushed the feat/v054-l2-aarch64-float branch from 193958c to 52a8d87 Compare July 30, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant