0.16.3: caller_fn i32-table refactor (WIP)#19
Closed
jasisz wants to merge 18 commits into
Closed
Conversation
Foundation refactor for the caller_fn i32-table redesign — codegen
becomes the single source of truth for which fn names land in the
exported table, no more AST walker.
Changes:
- `body::CallerFnCollector` (new): RefCell-friendly registry that
`emit_caller_fn_idx` lazily registers fn names into. Each call site
that wants a caller_fn slot gets one assigned in encounter order
(first call to `register("main")` → idx 0, etc.). Idempotent — the
per-fn probe pass in `module.rs` registers the same names twice
(probe + real emit), `register` returns the already-stored idx on
the second hit.
- `EmitCtx` carries `&RefCell<CallerFnCollector>`; `emit_fn_body`
takes the collector through its signature.
- `body/emit.rs::emit_caller_fn_idx` rewritten — instead of looking
up `registry.caller_fn_idx[self_fn_name]` (the 0.16.2 walker-built
map), it calls `collector.register(self_fn_name)` and emits
`i32.const <idx>`. Single source of truth.
- `effects.rs::params` trailing arg switched from `any_ref_ty()` to
`ValType::I32`. Host gets an idx instead of a String ref.
- AST walker `fn_body_emits_effect_call` removed from `types.rs`,
along with `caller_fn_idx` / `caller_fn_order` fields and the
pre-walker fn-name segment registration. The collector replaces all
three.
- `module.rs`: 0.16.2's GlobalSection + StartSection + init body for
`__init_globals` are gone. Their replacement (`__caller_fn_count`
+ `__caller_fn_name` exports plus the matching passive segments
appended to the data section) is the next step — see TODO comments
inline.
Status — partial:
- Compile: green (debug + release).
- 10/10 wasm-gc record-replay smokes pass — but only because the
host's `lm_string_to_host` falls through `Val::I32 → None →
"main"` for the new ABI shape, and every smoke asserts
`caller_fn: "main"` (which was 0.16.2's "main" too). The actual
feature (per-fn caller_fn labels) is broken until step 2 lands the
exports + body + segments + host wiring.
TODO step 2 (next session):
- 2 new wasm types: `__caller_fn_count: () -> i32` and
`__caller_fn_name: (i32) -> (ref null $string)`.
- 2 new fn entries in the function section, allocated last so their
wasm fn idxs come after every helper.
- 2 new exports.
- Post-emit body emit for both, reading `collector.names`.
- Append `collector.names` as fresh passive segments after the
pre-walked literal segments; bump the data count section.
- Host (Rust + JS): drop the LM round-trip in `dispatch_aver_import`,
read `params.last().i32()` directly, look up
`caller_fn_table[idx as usize]` from a `Vec<String>` materialised
once at instantiation by walking `__caller_fn_name(0..count)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
aver | 5e3d214 | Commit Preview URL | May 06 2026, 07:21 PM |
Wires up the compiler-side and host-side ends of the caller_fn name
table that step 1's lazy collector was already feeding. wasm-gc
binaries now self-host their fn name list — no external metadata, no
LM round-trip per call.
Compiler:
- Two new wasm types in `module.rs::compile_to_wasm_gc`:
`__caller_fn_count: () -> i32` and
`__caller_fn_name: (i32) -> (ref null $string)`. Allocated only
when the program has the `$string` slot (i.e. any user fn def).
- Two matching fn entries appended to the function section after
factory exports; their idxs are exported as `__caller_fn_count` /
`__caller_fn_name`.
- Pre-pass over user fn bodies populates the lazy collector with
every fn name that ever pushes caller_fn at a call site. Needed
before the data count section emit because passive segment count
is `string_literals.len() + collector.names.len()`.
- `__caller_fn_count` body: `i32.const N; end`.
- `__caller_fn_name(idx)` body: per-name `block { brif ne; emit;
br outer; end }` chain. One arm per registered fn name, each
materialising the matching String ref via `array.new_data` from
a freshly-appended passive segment. Default arm returns `ref.null`
for out-of-range idxs.
- Caller_fn name segments appended to the data section after the
pre-walked literal segments; data count bumped accordingly.
Rust host (`run_wasm_gc.rs`):
- New `RunWasmGcHost::caller_fn_table: Vec<String>` field, populated
at instance creation by `build_caller_fn_table` — calls
`__caller_fn_count`, then walks `__caller_fn_name(0..count)`,
decoding each ref via the existing `__rt_string_to_lm` bridge.
Empty vector when the module doesn't export the table (programs
with no fn defs).
- `imports.rs::dispatch_aver_import` swaps the per-call LM round-
trip for a vector index lookup: `params.last().i32() → caller_fn_
table[idx]`. Unused `lm_string_to_host` import removed.
JS host (`tools/website/playground/wasm_host.js`):
- `setInstance` calls `materialiseCallerFnTable(instance)` — same
shape as the Rust side, walks `__caller_fn_count` + `__caller_fn_
name(0..count)`, decodes via `averToJs`, caches in
`this.callerFnTable`.
- Per effect callback's trailing arg renamed `callerRef → callerIdx`
(Number now, not anyref). Helper `dec(...) → averToJs(ref)` swept
to `callerFnFromIdx(idx) → callerFnTable[idx] || "main"`.
Sizes (rebuild_playground.py with the fresh compiler):
- snake: 4506 → 4421 B (-85 B, -1.9%)
- tetris: 8723 → 8440 B (-283 B, -3.2%)
- life: 7097 → 6897 B (-200 B, -2.8%)
- wumpus: 5590 → 5312 B (-278 B, -5.0%)
- doom: 23197 → 20890 B (-2307 B, -9.9%)
- checkers: 23969 → 21617 B (-2352 B, -9.8%)
- rogue: 28666 → 26173 B (-2493 B, -8.7%)
Bigger games shed ~10% — the per-fn caller_fn globals (8 B decl + 9 B
init body each) dropped, replaced by one `i32.const` per call site
and a single name-table dispatch shared across all fns.
Verified end-to-end:
$ aver run --wasm-gc --record /tmp/cf.json examples/core/effects_explicit.av
Hello, Ada!
Goodbye, Ada!
$ python3 -c '...'
Console.print -> greet
Console.print -> farewell
10/10 wasm-gc record-replay smokes pass; 7/7 prebuilt games rebuild
+ validate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI Format step rejected the multi-line `caller_fn_collector` let- binding and a stray blank line in `types.rs`. Auto-applied `cargo fmt --all`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related fixes that lift the wasm-gc backend's
"Int/Float/Bool/String only" cap on record + sum field types in
hash and equality dispatch.
## Bug 1: list-hash on records (lists.rs:2073)
`emit_list_hash` was matching on the surface element string
(`elem.trim()`), so `List<Box>` where `Box(n: Int)` is newtype-
optimised to i64 reached the `other` arm and panicked with a
"file at github" comment that named the right culprit but didn't
fix it. The companion `let _ = kind;` line was a TODO marker —
the kind enum was supposed to be the dispatch axis.
Fix: dispatch by `kind` (`ListEqKind`) instead of the elem string.
Newtype-erased records resolve to their underlying primitive
arm; nominal record/sum elements get a new `RecordEq(name)` arm
that calls `emit_record_inline_hash` over the field list, and
sum elements get a `SumEq(parent)` arm that calls a new
`emit_sum_inline_hash` walking variants in `emit_sum_eq_inline`'s
sorted order (so eq + hash agree on which variant to inspect
first).
The same shape was duplicated in `emit_vec_hash` (`lists.rs:
2443`) — fixed identically: dispatch by kind, route record/sum
to the new inline emitters.
## Bug 2: list / vector hash for sum types
`emit_sum_inline_hash` materialises an i32 hash by mixing the
matched variant's `type_idx` (as a stable tag — empty variants
of the same parent get distinct hashes) and DJB2-folding each
primitive field. Variants are disjoint subtypes of the parent
struct, so at most one `ref.test` succeeds per call; non-matched
arms test to false and skip silently.
Restrictions inherited from `list_eq_kind`: variant fields must
all be `{Int, Bool, Float, String}`. Recursive ref / nested
record fields surface as `Validation` errors; bug 3 lifts that
on the eq side.
## Bug 3: recursive sum eq (lists.rs:1748, maps.rs:2300)
`emit_record_eq_inline` and `emit_sum_eq_inline` rejected any
field type outside `{Int, Float, Bool, String}` with
`WasmGcError::Unimplemented("phase 4 — …")`. That blocked every
recursive nominal type — `Tree.Leaf | Tree.Node(Int, Tree, Tree)`,
`List`-cell shapes, mutually-recursive sums.
Fix: thread two extra params into both inline emitters —
`eq_helper_fn_idx: &HashMap<String, u32>` and `self_fn_idx:
Option<u32>`. The dispatch grows two new arms:
- `field_ty == self_name && self_fn_idx.is_some()` →
`Call(self_fn_idx)`. Recursive ref to the same type, e.g.
`Tree.Node` carrying `Tree` fields.
- `eq_helper_fn_idx.contains_key(field_ty)` → `Call(idx)`. Nested
nominal type with its own `__eq_<X>` helper. Field refs are
subtypes of `eqref` so the implicit upcast at the call site
is fine.
The body emit in `body/eq_helpers.rs::EqHelperRegistry::emit_
helper_bodies` snapshots `self.slots → HashMap<&str, fn_idx>`
once and threads it + the per-helper `self_fn_idx` into the
inline emitters. List.contains call sites (lists.rs:1546/1559)
pass empty maps + `None` — `__eq_<X>` field dispatch only fires
inside helper bodies, not inline `List.contains`. (Lifting that
covers fewer use cases and is a follow-up.)
## Verified
- Bug 1 repro (`List<Box>` with `Box(n: Int)` record): wasm-gc
matches VM (`found: true`).
- Bug 2 repro (`List<Color>` with empty-variant sum): wasm-gc
matches VM (`found: true`).
- Bug 3 repro (`Tree.Node(Int, Tree, Tree); t1 == t2`): wasm-gc
matches VM (`eq: true`). Recursive call into the same
`__eq_Tree` helper resolves through `self_fn_idx`.
- 10/10 wasm-gc record-replay smokes pass.
## Followup
Bugs 4 (generic E) and 5 (ref/eqref) — TBD in next commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI Format + Clippy steps flagged: - One indentation diff in `lists.rs::emit_list_hash` from the new RecordEq locals match arm. - `clippy::too_many_arguments` on `emit_record_eq_inline` / `emit_sum_eq_inline` after threading `eq_helper_fn_idx` and `self_fn_idx` for recursive eq dispatch (now 8 args each). Splitting these into a context struct is cleaner long-term but not the right cut while we're still settling the dispatch shape; an `#[allow]` attribute is fine for the moment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User stress-tested the bug 1-3 fix with three exotic shapes;
two of them surfaced fresh regressions both rooted in the
`__eq_<TypeName>` helper machinery.
## Repros that triggered the fixes
- **A: record with sum field.** `record Item { name: String,
color: Color }`, `Item == Item`. Failed with
`record Item field type Color has no eq dispatch`.
- **B: sum with record field.** `type Cell = Empty | Filled(Pos)`
where `record Pos { x: Int, y: Int }`. Failed with
`expected (ref null $type), found eqref`.
- **C: mutually recursive sums.** `type A = A0 | A1(B); type B
= B0 | B1(A)`. Worked already after bug 3, kept as a
regression smoke.
## Fix 1: transitive `__eq_<X>` helper registration
`EqHelperRegistry::register` only ran on the types that the
user wrote `==` / `!=` on directly. Field types reached only
transitively — e.g. `Color` showing up as a field of `Item` —
never got a slot, so the bug 3 dispatch
`Call(eq_helper_fn_idx[field_type])` resolved to None and bailed
with the new validation error.
New `register_transitive(name, kind, registry)` walks the
record fields / sum variants and recurses on any nominal field
type. Idempotent on cycles (Tree → Tree.Node → Tree's
`register_transitive` is a no-op after the first hit), so
recursive shapes don't loop. Discovery walker in `module.rs`
swapped to call this instead of plain `register`.
## Fix 2: ref.cast prologue in `__eq_<Record>` body
The helper signature is `(eqref, eqref) -> i32` (uniform for
record + sum so call sites don't need per-type fn type idxs).
That was fine for sums — `emit_sum_eq_inline` runs `ref.test`
+ `ref.cast` per variant before reading fields. Records read
fields straight off the param via `struct.get $record_idx <i>`
which the validator rejects: `struct.get` wants a typed
`(ref null $record)`, not `eqref`.
`emit_helper_bodies::EqKind::Record` now declares 2 typed
locals on the helper, casts both params at the top of the body,
and drives `emit_record_eq_inline` against the typed locals.
Sum path is unchanged.
## Verified
- All three exotic repros (A/B/C) match VM output.
- Bug 1/2/3 repros still match.
- 10/10 wasm-gc record-replay smokes pass.
- All ~92 examples (core/data/games/projects) compile +
validate under wasm-gc.
`exo_b` was the shape memory's "bug 5: ref/eqref" referred to —
the validator message and structure match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User stress-test ("spróbuj egzotyków") surfaced that the bug-1-3 fix
covered nominal `==` directly on a record/sum (`Item == Item`) and
inside `__eq_<X>` field dispatch, but the surrounding ListEqKind /
ListHelperRegistry wiring still bailed out for collections holding
nominal elements:
- `List<Tree>` failed `list_eq_kind` because Tree.Node's recursive
`Tree` field tripped the old `all_simple` gate — primitive-only.
- `emit_list_eq` / `emit_list_contains` / `emit_vec_eq` then panic-d
or returned Unimplemented on the RecordEq/SumEq arm.
- The helper-body emit threaded `&HashMap::new()` for the eq helper
lookup map and `None` for self_fn_idx, so even when a kind landed
the inner `emit_record_eq_inline` / `emit_sum_eq_inline` couldn't
resolve nested-nominal field eq (`Item.color: Color`).
Lifts:
- `list_eq_kind` now accepts nominal elements whose fields are
themselves resolvable. `record_fields_resolvable` and
`sum_fields_resolvable` recurse with a `seen` set so cycles
(Tree → Tree.Node → Tree) terminate. Fields can be primitive,
another resolvable record, or another resolvable sum.
- `emit_list_eq` / `emit_vec_eq` no longer panic on RecordEq /
SumEq. They dispatch to the per-type `__eq_<X>` helper via
`Call(eq_helper_fn_idx[name])`. Both refs on the stack are
subtypes of eqref so the implicit upcast at the helper signature
is fine.
- `emit_list_contains`'s nested `emit_record_eq_inline` /
`emit_sum_eq_inline` calls now thread the actual eq-helper map
in (used to be empty), so a `List<Item>.contains(needle)` with
Item carrying a Color field resolves Color through
`Call(__eq_Color)` instead of bailing.
- `ListHelperRegistry::emit_helper_bodies` takes
`eq_helper_fn_idx: &HashMap<String, u32>` and passes it down.
`module.rs` snapshots `eq_helpers_registry.iter()` once and
hands it to both list_helpers' emit and the existing
eq_helpers' own bodies (where it was already wired in step 3).
- Discovery walker (`module.rs::discover_builtins_in_expr`) gains
`register_nominal_in_type(t)` that walks `t` recursively and
registers every Named record/sum it reaches. Needed because
`==` on `List<Tree>` (or `Map<Color, Box>`, `Option<Cell>`,
…) used to register nothing — `t` matched neither
`AverType::Named` nor a record/sum walker; the helper bodies
later tried `Call(__eq_<Tree>)` on an unregistered slot.
## Verified by stress repros
- A: `record Item { name: String, color: Color }`, `==`. Color
is now auto-registered transitively, dispatch via
`Call(__eq_Color)` resolves cleanly.
- B: `type Cell = Empty | Filled(Pos)` with `record Pos { x, y }`.
Pos registered transitively, helper signature ref.cast prologue
(step 3 fix) gives the typed ref `struct.get` needs.
- C: mutually recursive `type A = A0 | A1(B); type B = B0 | B1(A)`.
Both helpers register; recursive Call(self_fn_idx) closes the
loop.
- bugs 1, 2, 3 still match VM.
- 10/10 wasm-gc record-replay smokes pass.
- All 92 examples (core/data/games/projects) compile + validate.
## Known remaining shapes (out of 5-bug scope)
- `==` on `List<Tree>` directly (BinOp::Eq with operand type
`Type::List(_)`): `body/emit.rs::sum_or_record_eq_fn`'s lookup
is keyed by `Type::Named(name)` only, so `List<…>` falls
through to the default `i64.eq` arm. Fix would extend the
dispatch to call `fn_map.list_ops[canonical].eq` when the
operand type is `List<…>` / `Vector<…>`. Separate refactor —
flagged for 0.16.4.
- Records with `Option<…>` / `Result<…, …>` / `Tuple<…>` fields:
`emit_record_eq_inline`'s field type match needs a similar
generic-carrier dispatch path. Same followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "lift nominal eq cap" change tripped the rogue
playground test because:
- `record Entity { something: Option<Point>, ... }`. My new
`record_fields_resolvable` accepted Entity (Point itself is
primitive-fielded so it resolved), so `list_eq_kind("Entity")`
returned `Some(RecordEq("Entity"))` and a `List<Entity>` got
contains/eq/hash slots.
- Discovery's nominal seed sweep (also new) walked the registered
list element types and registered `__eq_Entity` transitively.
`register_field_type` for "Option<Point>" silently skipped (it's
not a primitive, record, or sum), so Entity got registered but
Option<Point> didn't — and `emit_record_eq_inline`'s field
dispatch later panicked.
Two complementary fixes:
- `register_transitive` now precomputes whether the type's fields
are resolvable via `lists::record_fields_resolvable` /
`sum_fields_resolvable` before registering. If any field type is
a generic carrier (Option / Result / List / etc.) the registration
is skipped — same gate `list_eq_kind` uses, so the two stay in
sync.
- `field_type_resolvable` rejects any field name containing `<`
(i.e. any generic carrier) outright. The inline eq emitters
don't have a dispatch for these yet — that's followup F.
Net: rogue compile_multi_file_rogue_from_virtual_fs passes again,
and List<Entity>-style shapes simply don't get a list contains/eq/
hash slot (programs holding such lists still compile, they just
can't compare lists or call .contains on them — same conservative
fallback as before this PR).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two stress-tests from the user's "popierdolone scenariusze" set
landed real holes that bugs 1-3+5 hadn't covered:
- F2: `record Person { name: String, age: Option<Int> }` then
`a == b`. Discovery walker registered `__eq_Person` but
`register_field_type` silently dropped the `Option<Int>` field
because it wasn't a primitive / record / sum. Helper body emit
later panicked: `record Person field type Option<Int> has no
eq dispatch`.
- G: `let a: Result<Box, Color> = Result.Ok(Box(val=5))` then
`a == b`. Two cascading misses:
- `Result<Box, Color>` wasn't even registered in the type
section because the discovery walker for Result canonicals
only scanned fn signatures + builtin uses, not let-binding
annotations.
- Even after registration, `body/emit.rs::sum_or_record_eq_fn`
matched `Type::Named` only — `Type::Result(_,_)` /
`Type::Option(_)` / `Type::Tuple(_)` operands fell through to
the default `i64.eq` arm.
- Inner `Box` field eq dispatched through the helper map,
but Box is newtype-erased to i64 — helper signature is
`(eqref, eqref)` so the validator caught the type mismatch.
## Fix
- `EqHelperRegistry` grows three new kinds — `OptionEq`,
`ResultEq`, `TupleEq`. Each instantiation gets its own helper
slot keyed by the canonical string (`"Option<Int>"`,
`"Result<Box,Color>"`, `"Tuple<Int,String>"`).
- `register_field_type` now recognises `Option<…>`, `Result<…,…>`,
`Tuple<…>` field types: registers the carrier helper, then
recurses on inner types so any nominal piece (e.g. `Option<Color>`
→ register `Color`) gets its slot too.
- `register_transitive` for carrier kinds skips the
`record_fields_resolvable` gate (carriers have one fixed shape;
inner-resolvability is tracked at the parent's
`field_type_resolvable` level).
- `lists.rs::field_type_resolvable` accepts carriers iff every
inner is resolvable — same recursive shape it already used for
Record/Sum.
- `eq_helpers.rs` grows three body emitters:
- `emit_option_eq_body`: cast both eqref → typed Option ref,
compare tags; differ → 0; both 0 (None) → 1; both 1 (Some) →
inner eq via `emit_inner_eq_dispatch`.
- `emit_result_eq_body`: same shape; tag=1 (Ok) → field 1 eq;
tag=0 (Err) → field 2 eq.
- `emit_tuple_eq_body`: per-field eq, AND-fold across all
elements.
- `emit_inner_eq_dispatch` resolves `newtype_underlying` before
dispatching — `Box(n: Int)` reaches as "Box" but lowers to i64,
so the primitive arm fires (helper map miss avoided).
- `body/emit.rs::sum_or_record_eq_fn` now matches `Type::Option` /
`Type::Result` / `Type::Tuple` and looks up the helper via the
whitespace-stripped canonical (`fn_map.eq_helpers["Result<Box,
Color>"]`).
- `module.rs::register_nominal_in_type` registers the outer
carrier canonical (so `==` on a `Result<…>` operand directly
flags `__eq_Result<X,Y>`), then recurses on inner types.
- `types.rs::TypeRegistry::build` walks let-binding annotations
for Result canonicals, mirroring what the Option/Vector/List
body walkers were already doing.
- `parse_result_kv` / `parse_tuple_elems` helpers in
`eq_helpers.rs` — depth-aware comma split so
`Result<Map<K,V>, MyError>` and `Tuple<Int, Result<…>>` parse
right.
## Verified
- A: `record Item { name: String, color: Color }` ==. Match VM.
- B: `type Cell = Empty | Filled(Pos)` with `record Pos`. Match VM.
- C: mutually recursive `type A`/`type B`. Match VM.
- D: `List<Tree> == List<Tree>` — still falls through to default
arm since BinOp::Eq dispatch for `Type::List` requires a
separate path. Documented as followup.
- F2: `record Person { age: Option<Int> }` ==. Match VM.
- G: `Result<Box, Color>` ==. Match VM (both Ok and Err arms).
- H: `record Move { via: Option<Pos> }` (carrier holding nominal).
Match VM.
- bugs 1, 2, 3, 5 still pass.
- 1100+ unit tests pass; 10/10 wasm-gc record-replay smokes;
rogue compile_multi_file test still green; flaky
buffer_build_pass test passed on retry (pre-existing
non-determinism, unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symmetric counterpart to the eq helpers: per-record / per-sum /
per-carrier `__hash_<X>` helpers replace the drop+0 fallback the
inline hash emitters were using for non-primitive fields.
## Why
`Map<Person, V>` keyed by a record (or any nominal/carrier shape)
wants a deterministic hash so two `Person` values that compare
equal collapse to the same bucket. The old fallback (drop the
field value, contribute 0 to the hash) was correct (eq still
disambiguates the bucket) but degenerate — every value sharing a
primitive prefix mapped to one bucket, lookup degraded to O(n).
## What
- New `body/hash_helpers.rs` registry analogous to
`body/eq_helpers.rs`. Same shape: register / register_transitive
/ assign_slots / emit_helper_types / emit_helper_bodies. Helper
signature is `(eqref) -> i32` (single operand vs eq's two).
- Per-kind body emitters in the new module:
- `emit_record_hash_body`: cast eqref → typed record ref, DJB2
fold over fields with proper inner dispatch.
- `emit_sum_hash_body`: ref.test cascade per variant; matched
arm folds variant `type_idx` (as a stable tag) plus per-
variant fields.
- `emit_option_hash_body`: tag fold; if Some, mix inner. Holds
h in a local across the if-arm so the block body can be
`BlockType::Empty` (avoids the stack-shape bug an earlier
`Result(I32)` shape had).
- `emit_result_hash_body`: same shape; if-arm picks ok-field
or err-field hash to fold based on tag.
- `emit_tuple_hash_body`: per-element fold, AND-style.
- `emit_inner_hash_dispatch` mirrors `emit_inner_eq_dispatch`:
resolves newtypes (`Box(n: Int)` → i64), handles primitives +
String + nominal/carrier helper map lookup. Fallback on
unresolved shapes still drops + 0 (collision-tolerant).
- Existing inline emitters (`emit_record_inline_hash` /
`emit_sum_inline_hash` in `lists.rs`) take a new
`hash_helper_fn_idx: &HashMap<String, u32>` parameter and
dispatch non-primitive fields through `Call(__hash_<X>)` instead
of dropping. Ditto `emit_list_hash` / `emit_vec_hash` and the
surrounding `ListHelperRegistry::emit_helper_bodies`.
- `module.rs` wires the registry: parallel sweep of the eq
registry's transitive closure (every type that gets an eq
helper also gets a hash helper, since list/vec/map helpers
dispatch both directions). New `FnMap.hash_helpers` for body
emit lookup.
## Verified
- Bugs 1, 2, 3, 5, A, B, C, D, F, G, H all pass — eq side
unchanged.
- 1500+ unit tests pass; 10/10 wasm-gc record-replay smokes;
rogue compile_multi_file_rogue_from_virtual_fs still green.
## What this *doesn't* fix yet
`Map<Person, V>` (where Person carries an Option<Int> field or
similar) still fails because `Map.set` looks up its key helpers
in the per-(K,V) `MapHelperRegistry`, which is a separate sweep
from `EqHelperRegistry` / `HashHelperRegistry`. Discovery for
that registry hasn't been taught about the carrier-resolvable
gate yet — that's a followup. The hash helpers are correctly
emitted; nothing reaches them through the map-key path today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MapHelperRegistry now accepts carrier types as K (or carriers in K record field positions). Carrier helpers don't allocate their own slot — they proxy via Call to the per-instantiation `__eq_<X>` / `__hash_<X>` helpers in eq_helpers / hash_helpers, threaded through emit_helper_bodies as `carrier_eq_hash`. types.rs: Map walker now scans let-binding annotations in fn bodies so `let m: Map<Person, Int> = …` is registered (mirrors the Result walker). Drops the unused `FnMap.hash_helpers` lookup field — record/sum/carrier hash bodies dispatch through the registry directly, and map-key proxy goes through the dedicated `carrier_eq_hash` parameter. Map<Person,Int> ✓ Person.age: Option<Int> Map<Tuple<Int,String>,V> ✓ Map<Result<E,T>,V> ✓ Map<Option<X>,V> ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds carrier (Option/Result/Tuple) and List/Vector handling at three
layers that were previously gated to primitives + flat record/sum:
- list_eq_kind extended with `CarrierEq(canonical)` for Option<X> /
Result<X,Y> / Tuple<…> / List<X> / Vector<X> elements. Element
dispatch is `Call(__eq_<canonical>)` — same calling convention as
record/sum, so the same kind variant covers all of them.
- field_type_resolvable now accepts List<X>/Vector<X> when the inner
type is itself resolvable, so a record with a `List<Option<Int>>`
field gets a registered eq+hash helper instead of falling through
to the i64-default validation error.
- register_field_type recurses into List/Vector inner types in both
eq_helpers and hash_helpers; carrier register_transitive now walks
inner types too so direct top-level seeding of a carrier still
picks up its inner shape (fixes tetris: `Option<PieceKind>` seeded
via List element walker without PieceKind itself getting a sum
helper otherwise).
- emit_helper_bodies (eq + hash) takes a `compound_lookup` arg
threading List<T>/Vector<T> fn idxs into the helper map so a
record field of compound type can `Call(__eq_List<…>)`.
- MapHelperRegistry whitelists List<T>/Vector<T> as K, with proxy
bodies in emit_eq_for / emit_hash_for. Map.remove now picks the
null heap type via `key_storage_null_heap` (concrete idx for
primitive-box / String / record / carrier / List / Vector,
abstract Eq for sum K) instead of the old primitive-only fallback
to type idx 0.
- Discovery seed walker registers carriers in eq_helpers /
hash_helpers when they appear as List/Vector elements or Map K,
so list-helper bodies dispatch carrier elements correctly.
Coverage:
shape_a Map<List<Int>, String> ✓
shape_d record { List<Option<Int>> } ✓
shape_h List<List<Int>> == ✓
shape_k record { Vector<Option<Int>> } ✓
shape_l Map<Vector<Int>, String> ✓
exo_hash Map<Person, Int> w/ Option field ✓
+ 7 carrier exotics, 3 bug repros, 7/7 games
621/621 lib tests, no regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Map<K,V> joins the eq/hash matrix as a first-class compound:
- MapHelperRegistry gets two new slots per instantiation: `eq` (eqref,
eqref) -> i32 and `hash` (eqref) -> i32. Bodies live alongside the
existing 11 kv helpers; type-section, function-section, and code-
section emit grow accordingly.
- `__eq_Map<K,V>(a, b)`: ref.cast both args, fast-fail on size mismatch,
then for each occupied bucket of a probe `b` via the per-(K,V) get
fn — `None` or value mismatch returns 0. Insertion order is
intentionally ignored (matches VM's `Value::Map(HashMap)` PartialEq +
Python/Java/Rust/Haskell mainstream).
- `__hash_Map<K,V>(m)`: XOR-fold per occupied entry of
`(djb2(k) << 5) + djb2(k) + djb2(v)`. XOR is commutative + associative
→ hash invariant under bucket / insertion order. Pairs with structural
eq for the standard hash-eq contract.
- V's helper resolution mirrors K — primitive V (Int/Float/Bool) inlines
the cmp + hash, ref V (String/record/sum/carrier/list/vec/map) flows
through `__eq_<V>` / `__hash_<V>`. assign_slots force-registers V as
pseudo-K so the helpers exist regardless of `Map<V, _>` reachability.
- Map<K,V> as a record/sum/list/vec field type now resolves: the
compound_eq_hash_lookup threaded into eq_helpers / hash_helpers /
list_helpers / map_helpers carries Map fn idxs alongside List<T>/
Vector<T>. Lookups normalize whitespace so source-side spacing
(`Map<Int, String>` from record_fields) finds the registry's
whitespace-free canonical.
- Map<K2,V2> as Map K is whitelisted in assign_slots; emit_eq_for /
emit_hash_for / key_storage_null_heap dispatch through the same
compound proxy pattern.
Discovery walker:
- types.rs adds `collect_maps_from_expr` — walks every Spanned<Expr>
in fn bodies and harvests Map<K,V> from `expr.ty()`. Catches map
literals (`{a => 3}` without annotation) where the canonical never
appears in any signature. Recurses through FnCall / List / Tuple /
IndependentProduct / MapLiteral / RecordCreate / RecordUpdate /
Constructor / Match / BinOp / Attr / ErrorProp / TailCall.
- list_eq_kind extended with `Map<…>` element kind; field_type_resolvable
recursively accepts Map<K,V> when both K and V resolve.
- eq_helpers + hash_helpers' register_field_type recurses into Map's
K and V so transitive helpers exist for the structural fold.
Coverage:
Map<K,V> == ✓ structural, order-invariant
record { Map<K,V> } ✓
List<Map<K,V>> / Vector<Map<K,V>> ✓
Map<List<X>, V> / Map<Vector<X>, V> ✓
Map<Map<K2,V2>, V> ✓
m = {a => 3} (no annotation) ✓ via expr.ty() walker
621/621 lib tests, 7/7 games, no regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Map.empty()` was redundant against the `{}` literal — same way `[]`
covers `List.empty` (which never existed). Bidirectional inference
already propagates the expected `Map<K,V>` from the binding annotation
into `{}`, and the discovery walker introduced in 0.16.3 picks up map
canonicals from `expr.ty()` so unannotated literals like `m = {a => 3}`
also work end-to-end.
Removed:
- typecheck signature + bidirectional case + map_calls arm
- VM dispatch (Value::Map and NanValue paths)
- 4 backends: codegen/builtins enum, rust/wasm/wasm-gc/lean/dafny
dispatch (lean and dafny had stale match arms that silently turned
into wildcard bindings after the enum drop — purged)
- types/map.rs `empty` / `empty_nv` fns + member registration
Migration:
- examples: data/map.av (5×), data/json.av (1×), formal/law_auto.av,
games/rogue (false positive — that's `Map.emptyMap`, a user fn,
untouched), tools/website/llms.txt
- lib tests: src/types/checker/tests.rs (2×)
- docs: language.md (set example), services.md (Map fn table),
vm.md (Immediate-tag note); CHANGELOG kept historical wording.
Vera-bench solutions/aver: 0 occurrences, no companion PR needed.
621/621 lib tests, no regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Int.parse`, `Float.parse`, `Int.rem` were registered in 4 backend
dispatch tables (codegen/builtins enum + name table, lean, dafny, rust)
but never reachable from Aver source — typecheck didn't know them, VM
didn't dispatch them. Lean and dafny treated them as outright aliases
for `Int.fromString` / `Float.fromString` / `Int.mod` (`IntFromString
| IntParse =>`, `IntRem | IntMod =>`). Half-cooked: enum variants
existed without the language ever exposing them.
Drop the 3 surface names and their `Builtin::Int{Parse,Rem}` /
`Builtin::FloatParse` enum variants from every backend they leaked
into. No migration needed — they were unreachable.
621/621 lib tests, build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops six redundant builtins, renames one for symmetry, bumps to
0.17.0. The convention `Target.fromSource` for type conversions wins;
operators (`+` on strings, arithmetic, comparisons) and literals
(`{}`, `[]`) cover composition; named functions only when they add
semantics that can't be expressed otherwise.
Removed:
- `Int.toString` → `String.fromInt(n)` or `"{n}"` interpolation
- `Float.toString` → `String.fromFloat(f)` or `"{f}"` interpolation
- `Float.toInt` → `Int.fromFloat(f)`
- `Int.toFloat` → `Float.fromInt(n)`
- `String.concat(a, b)` → `a + b`
Renamed:
- `Vector.toList(v)` → `List.fromVector(v)` (paired with the existing
`Vector.fromList(l)` under the same convention)
Migration:
- examples / docs / lib tests / website playground sources sed-migrated
in one batch; the `Float.fromInt` / `Int.fromFloat` confusion in
docs/language.md (sed double-replacement artifact) hand-fixed.
- VM body fns + dispatch tables in `types/{int,float,vector}.rs` purged
of the dropped names; `String.fromX` body fns in `types/string.rs`
unchanged (they're the canonical replacements).
- Builtin enum variants in `codegen/builtins.rs` dropped/renamed:
`IntToString` / `FloatToString` / `IntToFloat` / `FloatToInt` /
`StringConcat` / `VectorToList` → `IntFromFloat` / `FloatFromInt` /
`ListFromVector` (or removed for the duplicates).
- Backend dispatchers (rust, wasm legacy, wasm-gc, lean, dafny)
re-pointed to new names; lean/dafny opaque function declarations
(`IntToString`, `FloatToString`, `FloatToInt` as Lean/Dafny names)
kept — they're external-language conventions, not Aver surface.
- `wasm_gc::BuiltinName::IntToString` / `FloatToString` enum variants
kept as internal Rust labels (now mapped from `String.fromInt` /
`String.fromFloat`); renaming the labels was unnecessary churn.
- Self-host `aver_generated/` left as-is (will regenerate on next
self-host bootstrap; stale arms are dead since typecheck rejects the
old names).
Vera-bench solutions/aver: zero usage of any removed/renamed builtin
(checked again before bumping). No companion PR needed.
Cargo.toml: 0.16.2 → 0.17.0; aver-lsp's `aver-lang` path-dep version
bumped in lockstep.
621/621 lib tests, examples typecheck, runtime VM == wasm-gc on
data/map.av and friends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bulk sed-replace in [3+4/4] turned aliased arms like `"Int.toFloat" | "Float.fromInt"` into duplicate `"Float.fromInt" | "Float.fromInt"`, which clippy `-D warnings` flags as unreachable_patterns. Same for `"Int.toString"` aliases collapsing into duplicate `"String.fromInt"` entries. Plus an alias arm in `wasm_gc/body/builtins.rs` that became identical to the main `"Float.fromInt"` arm above it. Hand-removed 7 duplicates across rust/wasm/wasm-gc/ir/vm match ladders + 1 alias arm in body/builtins.rs. 621/621 lib tests, fmt clean, clippy `-D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's in
Caller_fn i32 table refactor (steps 1+2)
(ref null $string)globals +(start)-sectioninit replaced by an exported caller-fn name table:
__caller_fn_count() -> i32+__caller_fn_name(i32) -> ref null $string. Host walks them once at instantiation, caches aVec<String>, indexes per call. Effect imports' trailing arg isnow
i32(idx) instead ofanyref(String ref).EmitCtxreplaces the AST walker — codegenis the single source of truth for which fn names land in the
table. No more walker↔codegen rozjazdy (the kind of mismatch
that bit
emit_args_get_inlinein 0.16.2).Sizes (vs 0.16.2): doom, checkers, rogue all drop ~10%, smaller
games shed a few hundred B. Hot path is one
i32.constpereffect call — zero alloc, zero LM round-trip.
Backend bug 1: list-hash on records (
lists.rs:2073)emit_list_hashmatched on the surface element string, sonewtype-erased records (e.g.
Box(n: Int)lowered toi64)hit the
otherpanic arm. Fix: dispatch bykind(
ListEqKind), with a newemit_record_inline_hashhelperthat DJB2-folds primitive fields. Same shape applied to the
sibling bug in
emit_vec_hash.Backend bug 2: list / vector hash on sums
New
emit_sum_inline_hashwalks variants inemit_sum_eq_inline's sorted order, mixes the matched variant'stype_idx(as a stable tag — empty variants of the same parentget distinct hashes), DJB2-folds each primitive field.
Backend bug 3: recursive sum eq (
lists.rs:1748,maps.rs:2300)emit_record_eq_inlineandemit_sum_eq_inlinenow thread twoextra params:
eq_helper_fn_idx: &HashMap<String, u32>andself_fn_idx: Option<u32>. Field dispatch grows two arms:field_ty == self_name→Call(self_fn_idx)for self-recursive types like
Tree.Node(Int, Tree, Tree).eq_helper_fn_idx.contains_key(field_ty)→Call(idx)fornested nominal types with their own
__eq_<X>helper.Verified end-to-end with a recursive Tree repro:
t1 == t2matches VM behaviour.
What's not in
list don't reproduce on 0.16.3 HEAD — they were closed in the
0.16.0–0.16.2 cycle. The memory snapshot was stale.
fibonacci.avshows1.6181818181818**18**under wasm-gc vs…18**182**underVM). Different bug class (rounding), not from the 5-bug list,
separate followup.
Verified
trend down vs 0.16.2 baseline.
🤖 Generated with Claude Code