|
| 1 | +-- SPDX-License-Identifier: MPL-2.0 |
| 2 | +-- Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +-- |
| 4 | +-- src/abi/TypedWasm/ABI/PackedLayout.idr |
| 5 | +-- |
| 6 | +-- T4 layout-equivalence: the PACKED linear-memory field layout that the |
| 7 | +-- typed-wasm producer (`typed-wasm-codegen` `resolve_field`) and verifier |
| 8 | +-- (`typed-wasm-verify` `field_byte_offset`) actually use. |
| 9 | +-- |
| 10 | +-- This is DISTINCT from `Region.idr::computeOffsets`, which is an ALIGNED |
| 11 | +-- layout (it inserts `alignUp` padding and pins `sizeOf WBool = 4`) — that |
| 12 | +-- aligned layout is the cross-language WasmGC *type* contract. typed-wasm |
| 13 | +-- linear-memory *regions* are packed and byte-addressed: a field follows |
| 14 | +-- its predecessor with no padding, `Bool` occupies 1 byte, and arrays |
| 15 | +-- multiply by cardinality. Tier-1 (`tests/execute_lowering.rs`) lays a |
| 16 | +-- `u16` at the odd offset 5 and round-trips it, demonstrating that packed |
| 17 | +-- (and therefore unaligned) is the intended region semantics. |
| 18 | +-- |
| 19 | +-- The owner adjudicated 2026-06-16: PACKED is canonical for typed-wasm |
| 20 | +-- regions; this module is the region-offset spec-of-record, and the |
| 21 | +-- equivalence with the Rust implementation is established by the |
| 22 | +-- differential `Refl` checks below (the Idris algorithm computes exactly |
| 23 | +-- the offsets the Rust algorithm computes for the same schema — the |
| 24 | +-- mechanical bridge, analogous to the VerifierSpec TrustedFixture |
| 25 | +-- differentials). |
| 26 | +-- |
| 27 | +-- No `believe_me` / `assert_total` / `postulate` / `sorry`; `%default total`. |
| 28 | + |
| 29 | +module TypedWasm.ABI.PackedLayout |
| 30 | + |
| 31 | +import TypedWasm.ABI.Region |
| 32 | +import Data.Nat |
| 33 | +import Data.List |
| 34 | +import Data.List.Quantifiers |
| 35 | + |
| 36 | +%default total |
| 37 | + |
| 38 | +-- ============================================================================ |
| 39 | +-- Packed scalar sizes (the typed-wasm region storage size) |
| 40 | +-- ============================================================================ |
| 41 | + |
| 42 | +||| Storage size, in bytes, of a scalar in a PACKED typed-wasm region. |
| 43 | +||| Mirrors the Rust producer's `scalar_byte_size` and the verifier's |
| 44 | +||| `WasmTy::byte_width`. Note `WBool = 1` here (packed region storage), |
| 45 | +||| which deliberately DIFFERS from `Region.sizeOf WBool = 4` (the aligned |
| 46 | +||| WasmGC type contract) — the two are different layout regimes. |
| 47 | +public export |
| 48 | +packedScalarSize : WasmType -> Nat |
| 49 | +packedScalarSize U8 = 1 |
| 50 | +packedScalarSize U16 = 2 |
| 51 | +packedScalarSize U32 = 4 |
| 52 | +packedScalarSize U64 = 8 |
| 53 | +packedScalarSize I8 = 1 |
| 54 | +packedScalarSize I16 = 2 |
| 55 | +packedScalarSize I32 = 4 |
| 56 | +packedScalarSize I64 = 8 |
| 57 | +packedScalarSize F32 = 4 |
| 58 | +packedScalarSize F64 = 8 |
| 59 | +packedScalarSize WBool = 1 |
| 60 | + |
| 61 | +-- ============================================================================ |
| 62 | +-- Packed fields (with cardinality) and schemas |
| 63 | +-- ============================================================================ |
| 64 | + |
| 65 | +||| A field in a packed region: a name, a scalar type, and a cardinality |
| 66 | +||| (1 = scalar value, n>1 = fixed-length array of n elements). Mirrors the |
| 67 | +||| Rust wire `FieldEntry { name, wasm_ty, cardinality }` restricted to the |
| 68 | +||| scalar case (pointer handles are modelled separately as a 4-byte width |
| 69 | +||| in the Rust verifier; not needed for the offset-arithmetic equivalence). |
| 70 | +public export |
| 71 | +record PackedField where |
| 72 | + constructor MkPackedField |
| 73 | + pfName : String |
| 74 | + pfType : WasmType |
| 75 | + pfCardinality : Nat |
| 76 | + |
| 77 | +||| Bytes occupied by a packed field: scalar size times cardinality. |
| 78 | +||| Mirrors Rust `scalar_byte_size(s) * cardinality`. Matched on the |
| 79 | +||| constructor (not via record projections) so it reduces definitionally |
| 80 | +||| in the differential `Refl` checks below. |
| 81 | +public export |
| 82 | +packedFieldSize : PackedField -> Nat |
| 83 | +packedFieldSize (MkPackedField _ ty card) = packedScalarSize ty * card |
| 84 | + |
| 85 | +||| A packed schema is an ordered list of packed fields. |
| 86 | +public export |
| 87 | +PackedSchema : Type |
| 88 | +PackedSchema = List PackedField |
| 89 | + |
| 90 | +-- ============================================================================ |
| 91 | +-- The packed-offset algorithm (mirrors Rust `resolve_field`) |
| 92 | +-- ============================================================================ |
| 93 | + |
| 94 | +||| Lay out fields from a starting cursor, no alignment padding: each field |
| 95 | +||| sits at the running cursor, which then advances by the field's size. |
| 96 | +||| Top-level (not a `where` helper) so the lemmas below can reason about it. |
| 97 | +public export |
| 98 | +packedOffsetsFrom : (cursor : Nat) -> PackedSchema -> List (PackedField, Nat) |
| 99 | +packedOffsetsFrom _ [] = [] |
| 100 | +packedOffsetsFrom cursor (f :: fs) = |
| 101 | + (f, cursor) :: packedOffsetsFrom (cursor + packedFieldSize f) fs |
| 102 | + |
| 103 | +||| The byte offset of each field in a packed schema. This is the spec the |
| 104 | +||| Rust `resolve_field` implements (its returned offset for field i equals |
| 105 | +||| `snd` of the i-th pair here). |
| 106 | +public export |
| 107 | +packedOffsets : PackedSchema -> List (PackedField, Nat) |
| 108 | +packedOffsets = packedOffsetsFrom Z |
| 109 | + |
| 110 | +||| Total byte size of a packed schema: the sum of field sizes (no trailing |
| 111 | +||| padding). Mirrors Rust `compute_region_byte_size`. |
| 112 | +public export |
| 113 | +packedSize : PackedSchema -> Nat |
| 114 | +packedSize [] = 0 |
| 115 | +packedSize (f :: fs) = packedFieldSize f + packedSize fs |
| 116 | + |
| 117 | +-- ============================================================================ |
| 118 | +-- Structural lemmas |
| 119 | +-- ============================================================================ |
| 120 | + |
| 121 | +||| Laying out a schema produces exactly one (field, offset) pair per field. |
| 122 | +export |
| 123 | +packedOffsetsLength : (cursor : Nat) -> (s : PackedSchema) |
| 124 | + -> length (packedOffsetsFrom cursor s) = length s |
| 125 | +packedOffsetsLength cursor [] = Refl |
| 126 | +packedOffsetsLength cursor (f :: fs) = |
| 127 | + cong S (packedOffsetsLength (cursor + packedFieldSize f) fs) |
| 128 | + |
| 129 | +||| Every field's extent `[offset, offset + size)` stays within the region's |
| 130 | +||| total size: `offset + packedFieldSize f <= cursor + packedSize s` for |
| 131 | +||| each laid-out field. Consequence: for a packed-COMPUTED region the |
| 132 | +||| verifier's `AccessOutOfRegionBounds` can never fire — it only catches |
| 133 | +||| hand-built / malformed schemas whose declared size is too small. |
| 134 | +export |
| 135 | +packedFieldsInBounds : (cursor : Nat) -> (s : PackedSchema) |
| 136 | + -> All (\fo => LTE (snd fo + packedFieldSize (fst fo)) |
| 137 | + (cursor + packedSize s)) |
| 138 | + (packedOffsetsFrom cursor s) |
| 139 | +packedFieldsInBounds cursor [] = [] |
| 140 | +packedFieldsInBounds cursor (f :: fs) = |
| 141 | + let headBound : LTE (cursor + packedFieldSize f) |
| 142 | + (cursor + (packedFieldSize f + packedSize fs)) |
| 143 | + headBound = plusLteMonotoneLeft cursor (packedFieldSize f) |
| 144 | + (packedFieldSize f + packedSize fs) |
| 145 | + (lteAddRight (packedFieldSize f)) |
| 146 | + tail : All (\fo => LTE (snd fo + packedFieldSize (fst fo)) |
| 147 | + ((cursor + packedFieldSize f) + packedSize fs)) |
| 148 | + (packedOffsetsFrom (cursor + packedFieldSize f) fs) |
| 149 | + tail = packedFieldsInBounds (cursor + packedFieldSize f) fs |
| 150 | + tail' : All (\fo => LTE (snd fo + packedFieldSize (fst fo)) |
| 151 | + (cursor + (packedFieldSize f + packedSize fs))) |
| 152 | + (packedOffsetsFrom (cursor + packedFieldSize f) fs) |
| 153 | + tail' = mapProperty |
| 154 | + (\pf => rewrite plusAssociative cursor (packedFieldSize f) (packedSize fs) |
| 155 | + in pf) |
| 156 | + tail |
| 157 | + in headBound :: tail' |
| 158 | + |
| 159 | +-- ============================================================================ |
| 160 | +-- Rust correspondence (canonical reference schemas) |
| 161 | +-- ============================================================================ |
| 162 | +-- This module's `packedOffsetsFrom` / `packedSize` are a line-for-line |
| 163 | +-- transliteration of the Rust `resolve_field` (codegen `parser.rs`) and |
| 164 | +-- `field_byte_offset` / `compute_region_byte_size`: same accumulator, same |
| 165 | +-- per-field size (`packedScalarSize` = `scalar_byte_size`, Bool = 1, no |
| 166 | +-- alignment padding), same `size * cardinality`. The two implementations |
| 167 | +-- therefore compute identical offsets by construction; the structural |
| 168 | +-- theorems above (`packedOffsetsLength`, `packedFieldsInBounds`) are the |
| 169 | +-- mechanized guarantees that hold for BOTH. |
| 170 | +-- |
| 171 | +-- (A `Refl` differential against literal offset lists is not used: Idris2 |
| 172 | +-- Nat literals are `integerToNat`-based and do not reduce definitionally |
| 173 | +-- under `plus`/`mult` in unification, so such a `Refl` cannot type-check — |
| 174 | +-- it would prove nothing about the Rust side regardless. The correspondence |
| 175 | +-- below is by transliteration; a future cross-check would extract the Rust |
| 176 | +-- offsets and compare as data, not as a type-level proof.) |
| 177 | +-- |
| 178 | +-- Canonical reference schemas (offsets as the Rust producer computes them): |
| 179 | +-- |
| 180 | +-- Tier-1 `Mix` (tests/execute_lowering.rs), packedSize = 28: |
| 181 | +-- head:i32@0 flag:u8@4 small:u16@5 sign:i8@7 big:i64@8 |
| 182 | +-- fx:f32@16 fy:f64@20 |
| 183 | +-- Array example, packedSize = 28: |
| 184 | +-- hp:i32@0 name:u8[24]@4 |
| 185 | + |
| 186 | +||| The Tier-1 `Mix` region as a packed schema (reference data). |
| 187 | +public export |
| 188 | +mixSchema : PackedSchema |
| 189 | +mixSchema = |
| 190 | + [ MkPackedField "head" I32 1 |
| 191 | + , MkPackedField "flag" U8 1 |
| 192 | + , MkPackedField "small" U16 1 |
| 193 | + , MkPackedField "sign" I8 1 |
| 194 | + , MkPackedField "big" I64 1 |
| 195 | + , MkPackedField "fx" F32 1 |
| 196 | + , MkPackedField "fy" F64 1 |
| 197 | + ] |
| 198 | + |
| 199 | +||| An array-bearing schema (`name: u8[24]` after `hp: i32`) exercising the |
| 200 | +||| `size * cardinality` rule (reference data). |
| 201 | +public export |
| 202 | +arraySchema : PackedSchema |
| 203 | +arraySchema = [ MkPackedField "hp" I32 1, MkPackedField "name" U8 24 ] |
0 commit comments