Skip to content

0.16.3: caller_fn i32-table refactor (WIP)#19

Closed
jasisz wants to merge 18 commits into
mainfrom
0.16.3-caller-fn-table
Closed

0.16.3: caller_fn i32-table refactor (WIP)#19
jasisz wants to merge 18 commits into
mainfrom
0.16.3-caller-fn-table

Conversation

@jasisz
Copy link
Copy Markdown
Owner

@jasisz jasisz commented May 6, 2026

What's in

Caller_fn i32 table refactor (steps 1+2)

  • 0.16.2's per-fn (ref null $string) globals + (start)-section
    init 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 a
    Vec<String>, indexes per call. Effect imports' trailing arg is
    now i32 (idx) instead of anyref (String ref).
  • Lazy collector in EmitCtx replaces the AST walker — codegen
    is 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_inline in 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.const per
effect call — zero alloc, zero LM round-trip.

Backend bug 1: list-hash on records (lists.rs:2073)

emit_list_hash matched on the surface element string, so
newtype-erased records (e.g. Box(n: Int) lowered to i64)
hit the other panic arm. Fix: dispatch by kind
(ListEqKind), with a new emit_record_inline_hash helper
that 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_hash walks variants in
emit_sum_eq_inline's sorted order, mixes the matched variant's
type_idx (as a stable tag — empty variants of the same parent
get distinct hashes), DJB2-folds each primitive field.

Backend bug 3: recursive sum eq (lists.rs:1748,

maps.rs:2300)

emit_record_eq_inline and emit_sum_eq_inline now thread two
extra params: eq_helper_fn_idx: &HashMap<String, u32> and
self_fn_idx: Option<u32>. Field dispatch grows two arms:

  • field_ty == self_nameCall(self_fn_idx) for self-
    recursive types like Tree.Node(Int, Tree, Tree).
  • eq_helper_fn_idx.contains_key(field_ty)Call(idx) for
    nested nominal types with their own __eq_<X> helper.

Verified end-to-end with a recursive Tree repro: t1 == t2
matches VM behaviour.

What's not in

  • Backend bugs 4 (generic E) and 5 (ref/eqref) from the memory
    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.
  • Two FP precision diffs surfaced (fibonacci.av shows
    1.6181818181818**18** under wasm-gc vs …18**182** under
    VM). Different bug class (rounding), not from the 5-bug list,
    separate followup.

Verified

  • 10/10 wasm-gc record-replay smokes pass.
  • All core/data examples compile + validate under wasm-gc.
  • Bug 1 / 2 / 3 repros match VM output.
  • Playground rebuilds clean; aver_bg.wasm and per-game sizes
    trend down vs 0.16.2 baseline.

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

cloudflare-workers-and-pages Bot commented May 6, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
aver 5e3d214 Commit Preview URL May 06 2026, 07:21 PM

jasisz and others added 17 commits May 6, 2026 12:12
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>
@jasisz jasisz closed this May 6, 2026
@jasisz jasisz deleted the 0.16.3-caller-fn-table branch May 6, 2026 19:20
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