Skip to content

before: 32-bit correctness audit with a wasm32 execution leg - #30

Merged
plaidfinch merged 20 commits into
mainfrom
w2/wasm32-audit
Aug 19, 2026
Merged

before: 32-bit correctness audit with a wasm32 execution leg#30
plaidfinch merged 20 commits into
mainfrom
w2/wasm32-audit

Conversation

@plaidfinch

@plaidfinch plaidfinch commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

CAVEAT LECTOR: This PR and its comments were submitted by Claude, to aid in my own review of these changes.

Executes the 32-bit correctness audit under the ruled contract: no valid input that fits the target's memory space may be rejected or panic — principled correctness on wasm32 exactly as on 64-bit. Instruments before cures throughout: a wasm32 execution leg lands first with today's wrong behaviors pinned red, and each cure commit flips its pins green.

The execution leg (first commit)

Detached workspace crates/before/wasm32-pins/ (guest cdylib on wasm32-unknown-unknown, inputs synthesized in-guest, overflow-checks on; wasmtime harness, fresh instance per pin), eight pins wired into the gate's wasm stream (~3–9s total). Red baselines observed by execution, not argued.

Cures (each with its red→green flip)

  • Bits::len: usize multiply overflow at 2²⁹ bytes — sharper than the recorded finding: the release wrap is coincidentally correct at exactly 2²⁹ and wrong only above. Now u64 arithmetic, checked back to usize, derivation inline.
  • The four byte decode doors + borsh ReaderCursor: the whole-buffer bitvec views hit a seam sharper than recorded — at exactly 2²⁹ bits, bitvec silently constructs an empty view, so a valid 64 MiB input was silently rejected as Truncated (the worst class; the panic starts one byte up). Engineered around per the ruled contract: DsiCursor walks raw bytes at u64 positions; the doors gain byte-level grammar entries; borsh decode drops a copy and a bitvec round-trip.
  • Rank decode: the recorded Shl-expect seam at exp ≥ 2³², plus an unrecorded dashu buffer-capacity seam below it (from exp > 2³²−32). Cured by assembling the numerator from bytes — no value-width shift at all; Shr<u64> totalized (its former expect wrongly rejected computable zero results).
  • Party::encoded_bits / Clock::encoded_bits: view-construction panic and usize sum wrap; now O(1)/u64-checked.
  • Remaining widening sites classified inline with memory-pricing derivations (build.rs, stack.rs, gamma.rs, meter.rs, base.rs); suanpan's # Panics contracts verified exact with reachability derivations landed at before's relying call sites (suanpan itself: zero changes).
  • One board reading moved and is parent-measured in its re-pin commit: rank_decode worst-heap improved to 2.0 B/B default / 2.7 acceptance (from 3.00/3.67), with a small documented rise for integral-heavy ranks; all 2096 acceptance ceilings green.

Ruled-contract boundaries remaining (documented, loud, not landed — proposals only)

  1. Single streams > 512 MiB on 32-bit: storage positions are usize-denominated crate-wide; from_canonical asserts the storable bound with the derivation. Cure would be u64 storage positions (crate-wide internal reshaping).
  2. Post-decode walks over > 64 MiB values on 32-bit: the one Deref → &BitsSlice borrow caps at bitvec's fat-pointer limit; documented # Panics; the byte surface (eq/hash/canonical/re-encode) is exact at all storable sizes. Cure would be a crate-owned view type (188 uses, 48 files).
  3. Rank numerators ≥ ~2³²−32 bits on 32-bit (~604 MB minimum input — verified to be the format's true floor, no crafted shortcut exists): dashu's own loud release assert is the terminal. Cure is a backend change.

…nned red

just wasm-check proves before compiles for a 32-bit target; nothing in
the tree executed there, so 32-bit-only seams could pass every native
suite while misbehaving on wasm32. This leg is the instrument: a
detached guest/harness workspace (the fuzzfit idiom) whose guest drives
the public byte decode doors at the exact boundary sizes under a real
32-bit usize, and whose tests pin each boundary's behavior as found —
red first, per instrument discipline; the cures flip the same pins to
correct-value assertions.

The pinned bad baselines, verified by execution on wasm32:

- 67108864 bytes (exactly 2^29 bits): a valid Version encoding is
  SILENTLY rejected as Truncated — bitvec's element guard admits the
  slice and the span encoding's len << 3 shifts the length out of a
  32-bit usize, constructing an empty view. Silent wrong result, the
  worst class. (The recorded finding expected a panic here; the panic
  starts one byte up.)
- 67108865 bytes: bitvec's element guard panics — a valid encoding
  refused by panic.
- 536870912 bytes (512 MiB): panic; bitvec's cap masks the
  Bits::len() usize multiply overflow that begins at this size.
- rank exp = 2^32 - 8: panic inside dashu — from_be_bytes sizes its
  buffer from input bytes (leading zeros included) and overruns the
  backend's 32-bit MAX_CAPACITY even though the numerator value fits.
  A newly found seam below the recorded Shl seam, partially masking it.
- rank exp = 2^32: panic at Base's Shl<u64> usize::try_from, the
  recorded seam, at the smallest honest trigger (~604 MB: the format
  counts fraction depth from bits actually read, so no smaller stream
  reaches it).

Green adjacency witnesses ride beside each red pin (67108863 bytes;
exp = 2^32 - 32) so each flip is attributable to its seam, plus a small
round-trip/typed-reject liveness pin so a dead leg cannot read green.

The guest builds with overflow-checks on: the audited failure class
includes silent release wraps, and the checks turn exactly those into
observable traps. Wired into the gate's wasm stream (measured: ~3 s of
test wall time, all eight pins).

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The four byte decode doors (Party, Version, Clock, Span) and the borsh
ReaderCursor built one borrowed bit view over the whole input buffer.
bitvec's span encoding steals three usize bits for the head-bit offset,
so on a 32-bit target the view caps at usize::MAX >> 3 bits: at exactly
2^29 bits (a valid 64 MiB encoding) the length field wraps and the view
constructs EMPTY — the validator walks zero bits and silently rejects a
valid input as Truncated — and one byte past that, view construction
panics. The ruled contract says a valid encoding that fits the target's
memory decodes correctly, so the seam is engineered around, not
error-wrapped:

- DsiCursor now runs on u64 positions over raw (body, masked-tail)
  parts, with byte-door constructors (over_bytes, over_bytes_live) and
  a u64 end-position read; the usize conversions that remain are
  checked, each carrying the derivation for why an honest walk cannot
  trip it.
- The grammar walks grew byte-door entries (parse_id_bytes,
  validate_prefix_bytes, validate_dominating_bytes over a raw meet)
  returning u64 ends; the marker-padding judge got a raw-byte form and
  the slice form, now caller-less, dissolves into it.
- The borsh ReaderCursor buffers at u64 positions, windows gamma codes
  over its raw bytes, and finish() now hands back the field's canonical
  marker-padded bytes, adopted via from_canonical with no bitvec
  round-trip (one copy and one re-seal fewer than before).
- Bits::from_canonical asserts the storable bound (bit count fits
  usize): on 32-bit that is 512 MiB of buffer, where usize bit
  positions themselves run out — the documented seam the len() fix
  addresses next — and on 64-bit the bound is 2^61 bytes, dead by
  allocation. Bits' deref documents the borrowed view's remaining cap:
  decoded values past 64 MiB on 32-bit carry a correct byte surface
  (equality, hashing, canonical bytes, re-encode), while their walk
  surface still reads through the one bitvec borrow and fails loudly
  there; widening it means a crate-owned view type, proposed separately.

The wasm32 pins flip: both view-cap pins (64 MiB silent-empty,
64 MiB + 1 panic) now read the exact decoded bit lengths, and the
512 MiB pin's trap moved from bitvec's cap to the len() multiply it was
masking — re-pinned red at the unmasked seam, cured next. Native suite:
686/686 unchanged.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
bytes.len() * 8 is a usize multiply: on a 32-bit target it overflows at
exactly 512 MiB of buffer (2^32 bits) — a debug panic, and in release a
silent wrap that is coincidentally correct at exactly 2^29 bytes and
wrong (a length 2^32 short) for anything larger. The live length itself
still fits usize at that boundary, because the marker spends at least
one of the buffer's bits: 2^32 - 1 - trailing_zeros. So the arithmetic
runs at u64 width and converts back checked, with the doc carrying the
derivation for why the conversion cannot fail on a constructed stream
(from_canonical asserts the storable bound; freeze's build buffer is
bounded far below it).

The wasm32 pin flips: a valid 512 MiB version encoding — the largest
stored stream a 32-bit usize can denominate bit positions for — now
decodes on wasm32 with its exact live bit length, one marker byte shy
of usize::MAX.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The decoder rebuilt the numerator as (integral << exp) | (groups >> pad).
Two 32-bit seams lived in that line, both reachable from valid ~604 MB
inputs the address space holds: Base's Shl<u64> converts the shift amount
to the backend's usize, which fails at exp >= 2^32; and below it, dashu's
from_be_bytes sizes its buffer from the input's byte count — leading zero
bytes included — so 536870911 fraction bytes overran the backend's 32-bit
MAX_CAPACITY by one word even when the numerator's value fits.

The cure assembles bytes: num · 2^pad = integral · 2^(8·groups) + G, so
the numerator is the concatenation of the integral's minimal big-endian
bytes with the fraction groups, shifted right by the sub-byte pad (< 8,
in usize range on every target), leading zero bytes stripped so the
backend's buffer pays for value, not for zeros. No value-width shift
exists on the decode path at all. The encode side gets the dual guard:
the biased integral is decided by width (exp >= bits(num) means zero)
before any shift is materialized, so re-encoding a fraction-heavy rank
works at the same scales its decode admits.

Base's u64 shift forms are re-derived honestly: Shr<u64> is now total
(an amount at or past the value's width yields zero, so amounts past
usize clamp, value-preserving); Shl<u64> keeps its checked conversion
with the corrected rationale — on 32-bit it fires exactly where the
shifted result exceeds the backend's representable width, the same
values the backend's own capacity assert bounds, so both name an
unrepresentable result loudly (the earlier could-not-be-allocated claim
was false: a 512 MiB numerator allocates fine in a 4 GiB space).
Base::to_be_bytes lands as from_be_bytes' width-metered dual. The rank
Add/checked_sub alignment shifts and sum_ranks' accumulator shifts carry
their reachability derivations inline: honest exponents stay orders of
magnitude below every remaining bound.

The wasm32 pins flip: both rank boundary pins (exp = 2^32 - 8 at the
backend's byte-capacity seam, exp = 2^32 at the shift seam) now decode
and order exactly; the adjacency pin below the backend cap stays green.
Native rank suites: 40/40 unchanged.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
Test docs and driver prose narrated each pin's prior red state (the
baseline it used to trap on, what cured it). Provenance lives in git:
each doc now states the boundary the pin guards and the failure mode it
forecloses, as counterfactuals about the present code, with the pins
file's header naming the red-first discipline once. The trap-outcome
plumbing stays (a future seam pins red through it); its unused constant
goes.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The audit's ledger, written where the code is, each site carrying the
invariant that keeps its arithmetic exact on 32-bit targets:

- PackedBuilder's committed prefix states the width bound its four
  byte-to-bit position sites (len, patch_bit, read_bits, bit_at) rest
  on: build-side outputs are emitter-bounded over storage-capped
  operands, orders of magnitude below a usize wrap.
- BitStack::len states the same for its word widening: walk transients
  price depth in bits, and depth is bounded by the walked stream's
  capped length.
- The per-bit gamma decoder's prefix-overflow reject states what its
  arm names on each target: a value past the big-integer backend's
  representable width (32-bit), an unallocatable input (64-bit).
- The accumulator-shift call sites in the integral and web folds state
  why the shifted entry points' documented 32-bit panic (digit position
  past usize, shift 2^37) is unreachable from them: every shift is
  bounded by stored-stream content under 2^32.
- Packed::as_bits states why the instrument surface may keep its
  borrowed view where the decode doors walk raw bytes.

Two seams in this class are genuine 32-bit defects and get cures, not
comments: Party::encoded_bits reads the stored form's O(1) length
instead of constructing a borrowed view (which caps below the stored
sizes the doors admit), and Clock::encoded_bits sums its components at
u64 width with a checked conversion — each component's length fits
usize on 32-bit, but their byte-rounded sum need not, and a wrap there
was a silently wrong public answer.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
Each flagged doc's first paragraph shortens to its one-sentence summary,
with the detail moved below the paragraph break; no content changes.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
…-door cfgs

Three gate verdicts, three cures:

- The rank encode's integral extraction returns to (num >> exp) + 1
  whole: Shr<u64> is total (it clamps past the numerator's width), so
  the width-guard arm was a second spelling of the same boundary — and
  one that skipped the shift's width-scale limb record, reading the
  amp board's rank_encode limb floors from below (the floor was right:
  the walk genuinely reads the numerator; the record rides the shift).
- validate_bits' doc names validate_prefix_bytes, the production entry
  beside it.
- The byte-door companions gated to their one consumer's exact cfg
  (feature = borsh, not any(test, ...)): the default-feature test build
  compiles cfg(test) items without the borsh module that uses them, and
  clippy-default exists precisely to catch that dangling surface.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The rank decoder's numerator assembly moved the operation's allocation
profile, and the worst-case ranking pin caught the flip. Parent-measured
attribution (worktree at the byte-assembly commit's parent, same board,
same scales):

- freeze-parade (fraction-heavy): 3.00 -> 2.0 B/input-byte at the
  default scale, 3.67 -> 2.7 at acceptance — the dominant improvement;
  the assembly never materializes a shifted integral, so the family that
  paid that transient stops paying it.
- plateau-puncture: 2.74 -> 2.6 at default — slightly improved, and now
  the default-scale worst by overtake.
- bigroot (integral-heavy): at most 3.34 -> 3.7 at acceptance — a small
  constant rise, now the acceptance-scale worst: the byte image pays the
  integral's own bytes where the shift spelling folded them into its
  shifted buffer. Exponents hold at 1.00 and every acceptance ceiling
  stays green (2096/0), so the movement is a constant-factor trade
  inside the linear envelope, priced against the decode-path correctness
  the assembly buys at every scale.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The execution leg so far pinned the byte decode doors; five boundaries
in the walk, emit, and rank-arithmetic surfaces had no committed
baseline. Each now lands as-found (instruments before cures: every
later reshaping flips a committed red), with an adjacency witness just
inside the boundary so a failure is attributable to its seam:

- Composite doors (Ranked byte + borsh, Span borsh): the rank
  re-derivation and dominance re-walk read the version through the
  borrowed bit view, so valid keys trap from 67108864 bytes of version
  component; both view-cap genres pinned, green at 67108863.
- Semantic walks (Version PartialOrd, join): stored values the doors
  admit exactly cannot be compared or joined past the view cap; both
  genres pinned beside green witnesses, on values the decode pins
  already prove storable.
- Emitter output: two operands under every per-operand bound whose
  join concatenates trap when the finished stream reaches 67108864
  whole bytes (the builder's byte-backed buffer adopts into a bitvec
  whose length encoding caps at usize::MAX >> 3 bits); the largest
  emittable output in the family, 536870903 live bits, is the witness.
- Rank numerator at the backend word cap: a valid ~604 MB rank whose
  numerator is 2^32 - 24 value bits traps in the backend's buffer-fill
  assert though value and working set fit the address space; a
  numerator of exactly 2^32 - 32 bits (the cap itself) decodes green.
- Rank alignment gap: Add/checked_sub trap at an exponent gap of
  exactly 2^32 (the shift amount's usize conversion), and Add also
  traps just below the gap boundary when the aligned numerator
  outgrows the backend (gap 2^32 - 1, rank 1/2); gap 2^32 - 128 with a
  64-bit numerator is exact on both arms.

The guest gains the two-leaf skyline and integral-rank-stream
syntheses these shapes need (everything synthesized in-guest as
before), the borsh feature for the streaming doors, and a two-argument
export form; the harness gains call2 and re-exports Trap so pins can
name the exact trap they assert.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The gate's doclint caps a doc comment's first sentence at 220 rendered
characters; fourteen of the new pins' summaries ran long. Each now
opens with the short claim and carries its qualifiers in the body,
unchanged in content.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The walk surface reads stored streams and door buffers through
BitsView — borrowed bytes beside a u64 live bit length — so every
semantic walk (comparisons, joins' input side, admission walks, rank
folds, the id operations) is exact at every storable size on every
target. Bits's Deref to a borrowed bit slice, whose 32-bit length
encoding capped walks at 64 MiB, is deleted rather than widened;
Bits::live() is the explicit accessor in its place. Views are
byte-aligned by construction, so sub-stream ranges travel as explicit
(view, start, end) bit positions at the copy and parse seams.

The parallel byte-form duals existed only because the slice form
capped; each collapses to one form under the plain name:
require_marker_padding_bytes -> require_marker_padding,
parse_id_bytes -> parse_id, validate_prefix_bytes -> validate_prefix,
validate_dominating_bytes -> validate_dominating_from,
decode_int_window_bytes -> decode_int_window,
DsiCursor::over_bytes/over_bytes_live -> DsiCursor::new (BitsView::whole
/ BitsView::new carry the byte doors' forms), LeafCursor::open_bytes ->
LeafCursor::open. codec::byte_view dissolves into BitsView::body_tail —
views are byte-aligned by type, so its mid-byte None arm has no
representable input. The never-a-borrowed-bit-view door comments are
excised; prose is restated against the view.

The wasm32 pins flip in the same commit, per the pins file's own
red-first discipline: the composite-door pins (Ranked byte + borsh,
borsh Span) and the comparison pins at the 2^29-bit boundary go from
trap-assertions to correct-value assertions, with every adjacency
witness unchanged, and four new pins spot-check exactness deep in the
storable range (comparison at the 512 MiB storage bound; the composite
doors at 128-256 MiB components). The covered-join pins stay pinned as
traps with their mechanism re-attributed: the walk is exact at those
sizes, and the residual trap is the emitters' build-buffer length
encoding at the freeze seam — the same boundary the emitter-output pin
holds, cured by the crate-owned build buffer, not by this change. The
rank-cap and gap-boundary pins stay as found.

The skyline decode wrap now allocates its copy once, exactly sized plus
the marker bit (the meter envelopes tighten in the next commit). The
mutants roster's one line-pinned recode entry re-aims 457:38 -> 468:38
(same width-tie comparison, same equivalence argument; the counts
re-verify under just mutants-list).

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
…ed copy

The decode wrap allocates its copy once, exactly sized plus the marker
bit, so the peak heap over each decode scenario drops to the copy
itself. Parent-measured on the same idle machine, old -> new (bytes,
per scenario): dense 98304 -> 49152, cliff 3072 -> 1793, wide tooth
196608 -> 100049, alt spine 98304 -> 49152; hugeleaf is unmoved at
66752 (its peak is the wide payload arithmetic, not the copy). The
ceilings re-pin at the same proportional headroom: dense/alt spine
122880 -> 61440, cliff 3840 -> 2250, wide tooth 245760 -> 125100;
hugeleaf keeps 83440. Limb, scan, and segment columns are unmoved in
every scenario, so those pins and the limb floors stand.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The internal-docs build denies the bare link as ambiguous against the
encode submodule; name the function form explicitly.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The build side's one remaining 32-bit boundary was the bit vector behind
the emitters: its length encoding caps at usize::MAX >> 3 bits, so an
emitting operation whose output crossed 2^29 bits (a covered join of a
64 MiB operand, a two-operand join concatenating past it) trapped at the
freeze seam on wasm32 while every walk and door was already exact.

BitsBuf is the one build buffer now: bytes beside a u64 live length,
under two representation invariants every mutation maintains — the byte
vector holds exactly the live bits' bytes, and dead bits in the final
partial byte are zero (truncation masks the new final byte in O(1)).
The byte image is therefore a function of the bit content alone: Eq is
one memcmp, sealing appends only the marker, and the freeze seam adopts
the allocation whole. PackedBuilder keeps its staged-register internals
and word-parallel moves but runs its positions at u64 and hands its
finished bytes straight to BitsBuf — no length-encoding conversion binds
the hand-off on any target. The storable bound (usize-denominated bit
positions in the frozen form) is asserted loudly at the freeze door,
matching the decode door's existing contract. The gamma word path emits
through BitsBuf::push_bits; bitvec leaves the production dependency
graph (it stays a dev-dependency as emit_probe's external baseline).

The build-history proptest family pins the invariants as a family:
arbitrary interleavings of pushes, word appends, view copies, and
truncations (byte-aligned, mid-byte, to-empty, multi-byte sheds) leave
the buffer byte-identical to a clean rebuild of the surviving content at
every intermediate state, freeze to the identical canonical spelling,
and keep Eq and the canonical hash agreeing with bit-level equality;
a second leg pins spelling injectivity across distinct contents. A
transient no-zeroing mutant of truncate fails both legs (adequacy
verified before landing; the mutant is not committed).

wasm32-pins: the three output-side trap pins flip green in this same
commit, per the pins file's own discipline — the covered joins at and
past 2^29 bits now assert byte-identical emission, and the two-operand
emit at the coordinate asserts its exact 536870905-bit output. The
boundary prose across the pins re-denominates to the straddle
coordinate (the bit count where a 32-bit length encoding would bind),
which every surface is now exact across. The rank pins stay as found
for the later phase that owns them.

All 36 wasm32 pins pass; the resource-envelope suite passes with every
pinned ceiling and floor untouched.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
…eter-side

Stored streams, cursors, walks, and depths now speak one width: u64,
the denomination the build buffer and the view already carry. Bits::len
returns the u64 live length directly; the freeze and decode doors adopt
any buffer whose padding validates, imposing no bound of their own; the
BitCursor trait's position and read_unary are u64 (the separate u64
position read dissolves into them); BitStack heights, unary runs, tree
depths, flip levels, code lengths, and open-range counts widen across
the walk surface. On 32-bit targets streams are therefore bounded only
by allocatable memory: no usize-derived structural cap survives on the
stored, walk, or build path. Byte indexes stay usize (they index
allocated buffers), and each remaining u64-to-usize seam carries a
checked conversion with its rationale inline.

Where a gamma mantissa names a value wider than the big-integer backend
can hold on the target, both decode paths now reject with the value's
own genre (NotCanonical) at the same width, per-bit and word-parallel
alike.

Owner ruling (Finch, direct): encoded_bits on Party, Version, and Clock
leaves the unconditional public API — every consumer is a meter, a
coverage suite, a fuel denominator, or a boundary pin — and re-homes
behind the existing meter feature (the instrument surface), returning
u64. Consumers routed: laws now implies meter (the representational
laws read the observation), the fuzzfit harness and wasm32-pins guest
enable it explicitly, and rumors' dev-dependency lights it for the
conservation suite, whose invariant is unchanged. The surface roster's
rows are unchanged: the extractor scans source text, so the gated fns
keep their named rows.

The recode mutant exclusion's line pin moves with its expression
(rustfmt reflowed the file); same mutant, same rationale, re-pinned.
Meter parity: all 300 MEASURED rows are byte-identical to the parent
commit's readings.
…rdinate

With the doors and walks u64 end to end, the pins hold the new upward
reach and name what remains. The decode, comparison, emit, and rank
surfaces cross the 2^29-byte coordinate — where a 32-bit usize runs out
of bit positions — green: a 512 MiB + 1 byte version decodes with its
exact bit length and compares both ways, a join emits a finished stream
past 2^32 live bits, and a 768 MiB version decodes deep in the
memory-bounded range. A new ladder synthesis and export drive the rank
fold on numerators wider than any 32-bit quantity (2684354560 bits,
green), reachable only through depth weighting, never through a door.

Where the 4 GiB address space runs out, the terminal is pinned as
found: allocation-failure aborts — loud, never a silent wrong value —
on the ~1 GiB decode, the past-coordinate composite key, and the rank
fold at the big-integer backend's capacity coordinate. Probe backtraces
attribute each to accumulator buffer growth, so the backend's word cap
(2^32 - 32 bits) is unreachable through the version doors and the rank
fold on this target: a W-bit value or numerator keeps at least 2W bits
of stream alive underneath it, and that working set exhausts memory
just below the capacity. The rank wire door, which assembles its
numerator from bytes with no fold transients, is where the capacity is
reachable and stays pinned. A leaner working set — not a wider
denomination — is what would move these terminals outward.
The Magnitude seam lends a whole UBig, and on a 32-bit target a UBig
magnitude caps out near usize::MAX bits — so operands and totals wider
than the backend can hold had no way in or out of the accumulator. Two
entry points and one readout close that: add_limbs_shl/sub_limbs_shl
stream an operand as little-endian 64-bit limbs at any shift (the same
apply_limbs kernel and cost model as the wide entries, pinned
touch-identical by limb_stream_matches_the_wide_entry), and sign_limbs
reads the held value back as minimal limbs through the same carry pass
as sign_magnitude (read_magnitude's digit collection is now the shared
read_digits core). reserve_digits pre-sizes the digit buffer so a caller
folding operands of known final scale pays one exact allocation instead
of a growth-doubling transient — near a 32-bit target's memory ceiling
that doubling is the difference between fitting and failing.

The claims roster and crate-page table gain the new rows; the
differential streams gain limb-stream and reservation operations; the
metered suite pins the streaming entries' exact touch counts and
sign_limbs' carry pass; witnesses pin the limb readout's conversion
corners in both tiers and the reservation's value-neutrality. The
mutants exclusion roster's read_magnitude pattern follows the moved
high-part drains into read_digits, counts unchanged.
…rgets

The rank numerator becomes two-arm storage (version/rank/num.rs): the
backend magnitude wherever the backend can represent the value, a raw
little-endian limb vector past that — canonical by construction (wide
iff the width exceeds the backend's capacity), so structural equality
and hashing stay value equality and the arm choice is pure routing
between two exact implementations. On 64-bit targets the seam sits at
2^64 - 64 bits and never engages; on a 32-bit target it sits at
2^32 - 32 bits, inside the honestly reachable range of the rank wire
door.

What the wide arm totalizes, each previously a loud backend panic on
wasm32 at inputs that fit the address space:

- decode: both wire paths to a past-capacity numerator — the fraction
  image (~604 MB of input) and the integral mantissa (~512 MiB),
  including the biased transient at exactly the capacity — assemble
  limbs directly, never a backend value;
- Add and checked_sub: a routing predicate keeps the backend's
  shift-and-combine wherever both aligned operands and the carry fit it
  (the historical path, cost and metering identical), and routes past
  that through the streaming accumulator, whose digit buffer is
  reserved to the aligned width up front — no aligned numerator is ever
  materialized in the backend, so gaps at or past usize and aligned
  widths past the capacity are values priced by memory;
- Sum: summands enter at the width their arm stores (the wide arm via
  the streaming limb entry) and the total reads back out as limbs;
- encode, Ord, Eq, Hash, Display: the emission, the class-tie window
  comparison (one shared MSB-window kernel across arms, extracted from
  Base::msb_cmp), equality, hashing, and exact decimal rendering
  (schoolbook long division on the wide arm — quadratic, the documented
  price of exactness past the backend's reach) all operate per arm.

The wasm32 pins flip with the cure: the fraction-door decode past the
capacity, both rank-addition gap-boundary pins, and the checked_sub
gap-boundary pin now assert exact values (as-found: all four trap at
f615220). New adjacency pins hold the integral door below, at, and
past the capacity, and a full-width decode-then-re-encode byte-identity
pin holds the emission at the seam's far side. The memory-terminal
pins are untouched.

Host-side, a test-only arm-ceiling override (thread-local, routing
only) drives both arms and the seam through the public doors at small
sizes: the alignment-order sweep, the RANK_TRIPLE laws, sum-vs-fold,
arithmetic against backend-oracle alignment, normalization-family
proptests, codec round-trips, Display, and the Ranked composite door
all run in the wide regime, plus unit differentials for every wide-arm
operation against the backend as oracle. The limb meter's convention
extends to the wide arm — the same 64-bit-limb denomination, recorded
at the num module's operations — with the base arm's recordings
byte-identical to the historical numerator path.
The emit-probe example, the party bench header, the benchmark-results
README, and the wire-decode oracle's doc comment all described the
external bit-vector baseline as the crate's own storage or builder;
each now names it as the comparison baseline it is, with the crate's
word-staged PackedBuilder and BitsBuf as the shipping forms. The
builder's append assert drops a disjunct for a width its own
preceding assert excludes.
@plaidfinch
plaidfinch marked this pull request as ready for review August 19, 2026 16:37
@plaidfinch
plaidfinch merged commit 5d029c5 into main Aug 19, 2026
3 checks passed
@plaidfinch
plaidfinch deleted the w2/wasm32-audit branch August 19, 2026 17:02
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