Files
AILang/docs/journals/2026-05-11-iter-23.4.md
T
Brummel a1692a4859 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.
2026-05-11 23:59:45 +02:00

8.1 KiB

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::Fns — 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::Lams 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_typeclassesworkspace_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.rsMonoTarget 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_callsrewrite_mono_calls; workspace_has_typeclassesworkspace_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.rsapply_subst_to_term and descriptor_for_subst removed (dead post-Task-8).
  • crates/ailang-codegen/src/synth.rstype_descriptor removed (dead post-Task-8).
  • crates/ail/src/main.rsemit-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