iter 23.4: mono-pass unification — class methods + polymorphic free fns in one fixpoint
Restructure ailang-check/src/mono.rs::monomorphise_workspace into a single specialiser over every Type::Forall-quantified Def::Fn, covering both class-method residuals AND polymorphic free-fn call sites in one fixpoint pass. Remove the codegen-time specialiser (lower_polymorphic_call, module_polymorphic_fns, mono_queue, mono_emitted, emit_specialised_fn, plus the now-dead helpers apply_subst_to_term, descriptor_for_subst, type_descriptor) entirely. Codegen sees only monomorphic Def::Fns after this iter — no polymorphic call sites, no codegen-time queue. Architecture changes: - MonoTarget: struct → enum with ClassMethod / FreeFn variants; dedup keys are guaranteed-disjoint via 'class'/'free' first component. - collect_mono_targets: new FreeFnCall channel through synth (cleaner than the plan's separate-walker proposal — one less data flow, substitution tracking comes 'for free' via fresh metavars + post-synth subst.apply). Plan Step 4.4 (polymorphic_free_fns env field) consequently obsolete. - synthesise_mono_fn_for_free_fn: new free-fn entry point; substitutes rigid vars in BOTH the type AND the body (body substitution required because free-fn bodies carry inner Lams whose param_tys reference outer Forall vars — adapted from plan's 'body unchanged' sketch). - mono_symbol_n: N-ary extension of mono_symbol; bit-stable for the single-type-var case (hash-stability pin Task 1 fires zero times through all eight refactor tasks). - rewrite_class_method_calls → rewrite_mono_calls; interleaved-slot collector preserves the positional-cursor invariant across both channels. - workspace_has_typeclasses → workspace_has_specialisable_targets (principled correctness; old predicate happened to already be true for every user workspace because the prelude is auto-injected). Codegen cleanup: - Two call sites of lower_polymorphic_call deleted (codegen/src/lib.rs lines ~1939-1945, ~1965-1973). - lower_polymorphic_call body, module_polymorphic_fns field + population + Emitter wiring, mono_queue / mono_emitted, drain loop, emit_specialised_fn — all removed. - is_static_callee collapsed to module_user_fns-only. - subst.rs apply_subst_to_term + descriptor_for_subst removed; synth.rs type_descriptor removed (all dead post-removal). - ail emit-ir command pipeline gains lift_letrecs + monomorphise_workspace pre-passes (pre-iter-23.4 the codegen-time poly path was masking this dependency — emit-ir is now consistent with build). - Net codegen reduction: -285 lines lib.rs, -93 lines subst.rs. Tests: - mono_hash_stability.rs (new): regression pin for six primitive Eq/Ord mono-symbol body hashes; stayed bit-stable through all eight refactor tasks. - mono_unification.rs (new): six tests covering variant-key disjointness, free-fn target emission, free-fn synthesis with rigid substitution, rewrite walker on free fns, identity for poly-free-fn-only workspaces, end-to-end cmp_max_smoke runtime correctness. - typeclass_22b3.rs: three test sites updated to MonoTarget::ClassMethod enum form. - 73 e2e + 18 typeclass + 81 codegen tests all green workspace-wide. DESIGN.md §'Monomorphisation (post-typecheck, pre-codegen)' amended: two-arm fixpoint described, disjoint-keyed dedup, cursor-aligned walker, removed codegen-time path. Bench check (all exit 0, 112 metrics stable): bench/check.py + compile_check.py + cross_lang.py. Plan parent: docs/plans/2026-05-11-iter-23.4.md (fab1685). Spec parent: docs/specs/2026-05-11-23-eq-ord-prelude.md (841d65d). Per-iter journal documents two adapted deviations from the plan (synth-channel vs separate walker; body substitution; emit-ir fix); no spec defects.
This commit is contained in:
+49
-18
@@ -1511,21 +1511,49 @@ constraint context — which the user MUST have declared explicitly
|
||||
(per axis 2). No constraint is implicitly hoisted.
|
||||
|
||||
**Monomorphisation (post-typecheck, pre-codegen).** A pass between
|
||||
typechecking and codegen replaces every resolved class-method call
|
||||
with a call to a synthesised monomorphic `FnDef`. For each unique
|
||||
`(method, concrete-type)` pair encountered, the pass:
|
||||
typechecking and codegen replaces every call to a
|
||||
`Type::Forall`-quantified `Def::Fn` with a call to a synthesised
|
||||
monomorphic `FnDef`. Two source-body entry points share the same
|
||||
mechanics in one fixpoint:
|
||||
|
||||
1. Synthesises a top-level `FnDef` named deterministically from the
|
||||
method name and the canonical hash of the instance type. Body is
|
||||
the resolved instance method (or default body), with the class
|
||||
parameter substituted to the concrete type.
|
||||
2. Caches the synthesised def by `(method, type-hash)` so the same
|
||||
pair is not emitted twice.
|
||||
3. Rewrites the original `Call` to target the synthesised name.
|
||||
1. **Class-method entry.** For each unique `(method, concrete-type)`
|
||||
pair produced by a class-constraint residual, the pass looks up
|
||||
the resolved instance body via `Registry::entries[(class,
|
||||
type-hash)]`, substitutes the class parameter to the concrete
|
||||
type, and synthesises a top-level `FnDef` named
|
||||
`<method>__<type-surface-name>`.
|
||||
2. **Free-fn entry.** For each call site to a polymorphic free
|
||||
`Def::Fn` with a fully-concrete substitution, the pass takes the
|
||||
source body directly from the polymorphic `Def::Fn`, applies
|
||||
rigid-var substitution on both the type AND the body (the body
|
||||
may contain inner `Term::Lam`s whose `param_tys` reference the
|
||||
outer Forall vars), and synthesises a top-level `FnDef` named
|
||||
`<name>__<type-surface-name-1>__<type-surface-name-2>__…`
|
||||
(concatenated in `Type::Forall.vars` declaration order; the
|
||||
N-ary case extends the single-type-var class-method shape
|
||||
bit-stably).
|
||||
|
||||
After this pass, the IR contains no class machinery — only ordinary
|
||||
monomorphic functions and direct calls. Codegen sees no difference
|
||||
between a hand-written `show_int` and a synthesised `show__Int`.
|
||||
Both arms share:
|
||||
|
||||
- A fixpoint loop that keeps collecting targets until a round adds
|
||||
nothing new (a synthesised free-fn body may invoke class methods
|
||||
at concrete types, scheduling new class-method targets; a
|
||||
class-method body may invoke polymorphic free fns at concrete
|
||||
types, scheduling new free-fn targets).
|
||||
- A dedup cache keyed by `(kind, base-name,
|
||||
type-hash-or-joined-hashes)` where the first component
|
||||
(`"class"` / `"free"`) guarantees disjoint keying across the
|
||||
two kinds.
|
||||
- A call-site rewrite walker that rewrites bare polymorphic call
|
||||
sites — class-method-named OR poly-free-fn-named — to their
|
||||
mono symbols before codegen runs. The walker advances a single
|
||||
cursor over interleaved class-method and free-fn slots emitted
|
||||
in synth's traversal order.
|
||||
|
||||
After this pass, the IR contains no polymorphism, no class
|
||||
machinery, no polymorphic call sites — only ordinary monomorphic
|
||||
functions and direct calls. Codegen sees no difference between a
|
||||
hand-written `show_int` and a synthesised `show__Int`.
|
||||
|
||||
**Why mono, not virtual dispatch.** Monomorphisation makes the call
|
||||
target visible to the optimiser, unlocking inlining and downstream
|
||||
@@ -1548,11 +1576,14 @@ unambiguously into `<method>__<type-surface-name>` because neither
|
||||
component contains `__` by project convention.
|
||||
|
||||
**No runtime dispatch, no dictionary passing.** The monomorphisation
|
||||
pass is the ONLY mechanism for class-method calls. A call that
|
||||
cannot be monomorphised — for instance, because a constraint remains
|
||||
unresolved at the entry point — is a static error, not a runtime one.
|
||||
This is the LLVM-friendly form and is consistent with Decision 10's
|
||||
performance commitment.
|
||||
pass is the ONLY specialiser. Codegen sees only monomorphic
|
||||
`Def::Fn`s and direct calls; the pre-iter-23.4 codegen-time
|
||||
specialiser (`lower_polymorphic_call` + `module_polymorphic_fns` +
|
||||
`mono_queue`) was removed in iter 23.4. A call that cannot be
|
||||
monomorphised — for instance, because a constraint remains
|
||||
unresolved at the entry point — is a static error, not a runtime
|
||||
one. This is the LLVM-friendly form and is consistent with
|
||||
Decision 10's performance commitment.
|
||||
|
||||
### Defaults and superclasses
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# iter 23.4 — Mono-Pass Unification
|
||||
|
||||
**Date:** 2026-05-11
|
||||
**Started from:** fab1685
|
||||
**Status:** DONE
|
||||
**Tasks completed:** 11 of 11
|
||||
|
||||
## Summary
|
||||
|
||||
Restructured `crates/ailang-check/src/mono.rs::monomorphise_workspace`
|
||||
into a single specialiser over every `Type::Forall`-quantified
|
||||
`Def::Fn` in one fixpoint pass, covering both class-method residuals
|
||||
and polymorphic free-fn call sites. Removed the codegen-time
|
||||
specialiser entirely (`lower_polymorphic_call`, `module_polymorphic_fns`,
|
||||
`mono_queue`, `mono_emitted`, `emit_specialised_fn`, plus the dead
|
||||
helpers `apply_subst_to_term`, `descriptor_for_subst`, `type_descriptor`).
|
||||
Codegen now sees only monomorphic `Def::Fn`s — no polymorphic call
|
||||
sites, no codegen-time queue.
|
||||
|
||||
The unified pass produces bit-identical mono-symbol bodies for the
|
||||
six existing primitive Eq/Ord symbols (hash-stability pin Task 1,
|
||||
`crates/ail/tests/mono_hash_stability.rs`) and produces new free-fn
|
||||
mono symbols on demand for fixtures like `cmp_max_smoke` (Tasks 2,
|
||||
6, 9). All 73 e2e tests + 18 typeclass tests + 81 codegen tests pass
|
||||
end-to-end against the new architecture.
|
||||
|
||||
## Per-task notes
|
||||
|
||||
- iter 23.4.1: hash-stability regression pin — `examples/mono_hash_pin_smoke.ail.json` + `mono_hash_stability.rs` with six pinned hashes (`eq__Int`/`Bool`/`Str`, `compare__Int`/`Bool`/`Str`).
|
||||
- iter 23.4.2: `cmp_max_smoke` fixture + RED workspace-level acceptance gate.
|
||||
- iter 23.4.3: `MonoTarget` struct → enum with `ClassMethod` / `FreeFn` variants; `mono_target_key` widened with `"class"` / `"free"` first component (guaranteed-disjoint keys).
|
||||
- iter 23.4.4: free-fn arm in `collect_mono_targets`. NOTE: the plan's Step 4.3 sketch proposed a separate walker over `Term::App` plus a new `env.polymorphic_free_fns` index; instead I extended `synth` itself with a parallel `&mut Vec<FreeFnCall>` channel (mirroring the existing `&mut Vec<ResidualConstraint>` mechanism). One less data flow, one less helper; substitution tracking comes "for free" via fresh metavars + post-synth `subst.apply`. Plan's Step 4.4 (adding `polymorphic_free_fns` to `Env`) is therefore obsolete; not landed.
|
||||
- iter 23.4.5: `synthesise_mono_fn_for_free_fn` + N-ary `mono_symbol_n`. One substantive deviation from the plan's sketch: I also substitute rigid vars in the BODY via a new `crate::substitute_rigids_in_term` helper. The plan said "passes through unchanged" mirroring the class-method arm — but free-fn bodies (e.g. `std_list.length`) carry inner `Term::Lam`s whose `param_tys` reference outer Forall vars; without body substitution, Phase 3's re-synth would fail unification on unbound rigid `a`.
|
||||
- iter 23.4.6: `rewrite_class_method_calls` renamed to `rewrite_mono_calls`; new helper `poly_free_fn_names_for_module` builds the per-module set of bare + qualified poly-fn names; `collect_residuals_ordered` walks the AST emitting interleaved slots via new `interleave_slots` helper. The walker and slot collector share the same predicate so the cursor invariant survives across the two channels.
|
||||
- iter 23.4.7: `workspace_has_typeclasses` → `workspace_has_specialisable_targets`. NOTE: the plan's expected "FAIL → PASS after generalisation" was incorrect because the auto-injected prelude (iter 23.1) already keeps the old predicate true for every user workspace. The generalisation is principled-correctness only; documented in the predicate docstring.
|
||||
- iter 23.4.8: codegen cleanup — removed `lower_polymorphic_call`, both call sites, the Emitter fields (`module_polymorphic_fns`, `mono_queue`, `mono_emitted`), `emit_specialised_fn`, Pass-1 poly population, and the unused dead helpers (`apply_subst_to_term`, `descriptor_for_subst`, `type_descriptor`). `is_static_callee` collapsed to its mono-only path. Two follow-on fixes were required beyond the plan: (a) Unit-default fallback for unpinned forall vars in the mono pass — mirrors pre-iter-23.4 codegen behaviour for sites like `is_empty(Nil)` where the elem type is unobservable from the args alone; (b) updated the `ail emit-ir` command pipeline to include `lift_letrecs` + `monomorphise_workspace` (pre-iter-23.4 the codegen-time poly path was masking this dependency — `emit-ir` was broken for poly fixtures without the explicit mono call now that codegen no longer handles it).
|
||||
- iter 23.4.9: end-to-end verification — `cmp_max_smoke_runs_end_to_end` proves `cmp_max(3, 7)` at Int prints 7; the three existing poly fixtures (`polymorphic_id_at_int_and_bool`, `polymorphic_apply_with_fn_param`, `poly_rec_capture_demo`) still run.
|
||||
- iter 23.4.10: `docs/DESIGN.md` §"Monomorphisation (post-typecheck, pre-codegen)" amended to describe the two-arm fixpoint, the dedup key with disjoint `"class"` / `"free"` prefixes, the cursor-aligned walker, and the removed codegen-time path.
|
||||
- iter 23.4.11: bench-regression check — `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py` all exit 0; no metrics regressed.
|
||||
|
||||
## Concerns
|
||||
|
||||
- Plan Step 4.4 was bypassed (no `env.polymorphic_free_fns` field added). The synth-channel approach is structurally cleaner; the plan's index is unneeded. If a future iter needs an O(1) "is this name a poly free fn" predicate at non-synth call sites, the helper `poly_free_fn_names_for_module` (in mono.rs) builds the set on demand.
|
||||
- Plan Steps 5.3 / 7's "body unchanged" / "early-out fires for class-free workspaces" assumptions were both wrong against the actual codebase shape (free-fn bodies carry rigid vars in inner Lams; prelude is auto-injected so no workspace is class-free). Both were caught and adapted during implementation — no spec defect, just plan-draft drift against the live codebase.
|
||||
|
||||
## Known debt
|
||||
|
||||
- None tracked; all out-of-scope items from the plan (five prelude free fns ne/lt/le/gt/ge, `examples/prelude.ail.json` edits, the negative E2E fixtures `eq_ord_polymorphic` / `eq_ord_user_adt`, DESIGN.md §"Decision 11" amendment, Float-NoInstance diagnostic wording, roadmap update) remain owned by iter 23.5 as planned.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `crates/ailang-check/src/lib.rs` — new `FreeFnCall` struct; `synth` signature gains `&mut Vec<FreeFnCall>`; Var arm tracks `(owner_module, unqualified_name)` and pushes `FreeFnCall` for `Type::Forall`-resolved non-local refs; new `substitute_rigids_in_term` helper.
|
||||
- `crates/ailang-check/src/mono.rs` — `MonoTarget` struct → enum (`ClassMethod` / `FreeFn`); `mono_target_key` widened; new `mono_symbol_n` (N-ary); new `synthesise_mono_fn_for_free_fn`; new `poly_free_fn_names_for_module`; new `interleave_slots`; rename `rewrite_class_method_calls` → `rewrite_mono_calls`; `workspace_has_typeclasses` → `workspace_has_specialisable_targets`; fixpoint loop matches on target variants.
|
||||
- `crates/ailang-check/src/lift.rs`, `crates/ailang-check/src/builtins.rs` — synth call sites pass new `&mut free_fn_calls` Vec.
|
||||
- `crates/ailang-codegen/src/lib.rs` — deletions per Task 8 (Emitter fields, `lower_polymorphic_call`, `emit_specialised_fn`, mono drain loop, Pass-1 poly population, `is_static_callee` poly arm). `use` import shrunk.
|
||||
- `crates/ailang-codegen/src/subst.rs` — `apply_subst_to_term` and `descriptor_for_subst` removed (dead post-Task-8).
|
||||
- `crates/ailang-codegen/src/synth.rs` — `type_descriptor` removed (dead post-Task-8).
|
||||
- `crates/ail/src/main.rs` — `emit-ir` command gains `lift_letrecs` + `monomorphise_workspace` pre-passes (matches `build` shape).
|
||||
- `crates/ail/tests/typeclass_22b3.rs` — updated three test sites to `MonoTarget::ClassMethod` enum form.
|
||||
- `crates/ail/tests/mono_hash_stability.rs` (new) — regression pin for six primitive Eq/Ord mono-symbol body hashes.
|
||||
- `crates/ail/tests/mono_unification.rs` (new) — six tests covering variant key disjointness, free-fn target emission, free-fn synthesis with rigid substitution, rewrite walker on free fns, identity for poly-free-fn-only workspaces, and end-to-end `cmp_max_smoke` runtime correctness.
|
||||
- `examples/cmp_max_smoke.ail.json` (new) — `cmp_max : forall a. Ord a => (a, a) -> a` composition fixture.
|
||||
- `examples/mono_hash_pin_smoke.ail.json` (new) — fixture exercising the six primitive Eq/Ord mono symbols for hash-stability pinning.
|
||||
- `docs/DESIGN.md` — §"Monomorphisation (post-typecheck, pre-codegen)" amended.
|
||||
|
||||
## Stats
|
||||
|
||||
bench/orchestrator-stats/2026-05-11-iter-23.4.json
|
||||
@@ -13,3 +13,4 @@
|
||||
- 2026-05-11 — iter 23.4-prep: checker prerequisites for prelude free fns → 2026-05-11-iter-23.4-prep.md
|
||||
- 2026-05-11 — iter gc.1: grounding-check agent + brainstorm Step 7.5 → 2026-05-11-iter-gc.1.md
|
||||
- 2026-05-11 — iter disc.1: boss-only commits + main-as-quarantine (no branches in implement) → 2026-05-11-iter-disc.1.md
|
||||
- 2026-05-11 — iter 23.4: mono-pass unification (class methods + polymorphic free fns in one fixpoint; codegen-time specialiser removed) → 2026-05-11-iter-23.4.md
|
||||
|
||||
Reference in New Issue
Block a user