Iter 12c: docs for polymorphism
DESIGN.md:
- "What is not (yet) supported" updated for end-of-Iter-12 boundary:
parameterised ADTs replace HM-inside-bodies as the headline gap;
polymorphism limitations (direct-call-only, no higher-rank) are
spelled out so future me doesn't trip over them.
- "What is supported" gains the polymorphism + monomorphisation
bullet, with the descriptor scheme written out.
- Smoke-test list extended with poly_id and poly_apply.
JOURNAL.md: Iter 12a/b retrospective. Notes the metavar-encoding
choice (Type::Var{name:"$m<n>"} vs new variant) and why the
codegen-side type tracker was preferable to a typechecker
sidetable for now. Architecture self-check confirms the language
is now usable for poly-flavoured programs over primitives. Plan 13:
parameterised ADTs as the natural next step (without them, generic
map remains hand-monomorphised).
Iter 12c was originally to include a polymorphic map rewrite —
dropped because parameterised ADTs are the prerequisite. The two
new examples (poly_id, poly_apply, both already in 12b) cover the
real e2e proof.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+132
@@ -892,3 +892,135 @@ actually needs generic data — is HM inference + let-generalisation
|
||||
types in fn signatures.
|
||||
12c. Docs + a polymorphic `id` test + a generic `map :: (a -> b)
|
||||
-> List a -> List b` rewrite of `list_map.ail.json`.
|
||||
|
||||
## 2026-05-07 — Iter 12a/b done: polymorphism reaches the binary
|
||||
|
||||
Skipped 12c's "polymorphic map" — without parameterised ADTs (which
|
||||
the MVP doesn't have), the rewrite would still be over a concrete
|
||||
`IntList`, defeating the purpose. So 12c becomes lighter: docs +
|
||||
two new examples (`poly_id`, `poly_apply`) that prove polymorphism
|
||||
end-to-end on primitive types and on fn-typed parameters. The big
|
||||
test is whether *I* would use the language now for a poly-flavoured
|
||||
program; the answer below.
|
||||
|
||||
**12a — typechecker:**
|
||||
|
||||
`Type::Forall { vars, body }` is now legal at top-level fn types.
|
||||
Implementation is the textbook ML rule: peel the Forall when
|
||||
checking the body (rigid vars go into `Env.rigid_vars` so
|
||||
`check_type_well_formed` accepts them), instantiate fresh metavars
|
||||
at every var-resolution site, unify on every formerly-`expect_eq`
|
||||
edge.
|
||||
|
||||
The metavar encoding sidesteps an AST schema change: a metavar is
|
||||
just `Type::Var { name: "$m<id>" }`. The `$` prefix can't collide
|
||||
with source identifiers, the JSON layout doesn't shift, and module
|
||||
hashes stay bit-identical (verified: `sum.ail.json` keeps
|
||||
`db33f57cb329935e` / `d9a916a0ed10a3d3`). I considered adding a
|
||||
new `Type::Meta` variant under `#[serde(skip)]` but that would
|
||||
have pulled hashing concerns into serde; the naming convention
|
||||
keeps the AST untouched.
|
||||
|
||||
`Subst` is a flat `BTreeMap<u32, Type>`; `unify` is the standard
|
||||
occurs-check version with effects compared as a set. Constants
|
||||
still reject Forall outright; ADT fields still reject vars. No
|
||||
let-generalisation: lambdas inside fn bodies are checked
|
||||
monomorphically against their declared types — keeps the
|
||||
implementation small and matches DESIGN.md's "top-level types
|
||||
must always be explicitly annotated".
|
||||
|
||||
**12b — codegen:**
|
||||
|
||||
Direct calls to a polymorphic def get monomorphised on demand.
|
||||
Each unique (def, instantiation) pair emits a specialised LLVM fn
|
||||
with mangling `@ail_<m>_<def>__<descriptor>`. Descriptor scheme:
|
||||
`Int → I`, `Bool → B`, `Unit → U`, `Str → S`, ADT `Foo → FFoo`,
|
||||
`Fn(a)→b → Fn_<a>__r_<b>`. So `id(42)` and `id(true)` produce
|
||||
`@ail_poly_id_id__I` and `@ail_poly_id_id__B` side by side.
|
||||
|
||||
Pass 1 of `lower_workspace` now splits fn-typed defs into mono
|
||||
(`module_user_fns`, LLVM-typed FnSig as before) and poly
|
||||
(`module_polymorphic_fns`, full FnDef). A unified
|
||||
`module_def_ail_types` carries AILang types for both, used by
|
||||
the codegen-side type tracker.
|
||||
|
||||
The hard part was getting AILang types at call sites. The
|
||||
typechecker has them but doesn't hand its annotations down (no
|
||||
TIR yet). I considered three paths:
|
||||
1. Typechecker sidetable keyed by AST node ids — would need
|
||||
to assign ids deterministically, brittle.
|
||||
2. Uniform representation (everything passes as ptr/i64) —
|
||||
contradicts CLAUDE.md's "performance is extremely important".
|
||||
3. Codegen replays the type derivation locally.
|
||||
Picked (3). The trade-off is duplication (`synth_arg_type`
|
||||
mirrors what the typechecker already did), but it's contained
|
||||
to a small recursive walk and uses the same `locals`/`extras`
|
||||
shadowing pattern. Worth it for the MVP — once a TIR stage
|
||||
materialises (it's still on the debt list), the duplication
|
||||
collapses into a single pass.
|
||||
|
||||
`locals` grew from 3-tuple to 4-tuple `(name, ssa, llvm_type,
|
||||
ail_type)`. Six push sites updated mechanically. Lambda capture
|
||||
metadata grew the same way. `CtorRef` got `ail_fields` so match
|
||||
arm bindings inherit the AILang type.
|
||||
|
||||
The drain phase iterates until `mono_queue` is empty —
|
||||
specialised bodies can themselves invoke polymorphic defs and
|
||||
queue further entries. `apply_subst_to_term` substitutes rigid
|
||||
vars in `Term::Lam` annotations (the only Term arm carrying
|
||||
types).
|
||||
|
||||
**Architecture self-check:**
|
||||
|
||||
- *Would I use this language now?* For monomorphic programs:
|
||||
yes (already established). For polymorphism over primitives
|
||||
and fn-typed parameters: yes — `id` and `apply` write out the
|
||||
way the textbook says they should, with no language-level
|
||||
bookkeeping leaking into the source. The `poly_apply` example
|
||||
was particularly revealing: the closure-pair ABI (Iter 8a)
|
||||
composes cleanly with monomorphisation. Specialised body of
|
||||
`apply__I_I` keeps `f` as a fn-typed local; the existing
|
||||
indirect-call path already handles the lower from there.
|
||||
- *Did I think of everything?* No, two known gaps:
|
||||
1. **Polymorphic fn passed as a value** (`let f = id in f(42)`)
|
||||
fails in codegen — `resolve_top_level_fn` looks in
|
||||
`module_user_fns` only. Adding this means emitting one
|
||||
closure-pair global per instantiation, possibly via the
|
||||
same drain pass. Defer.
|
||||
2. **Higher-rank polymorphism** (`apply(id, 42)`) trips
|
||||
`unify_for_subst` which doesn't handle Forall on the param
|
||||
side. Real higher-rank polymorphism is a substantial step
|
||||
and not on the near horizon — deferred to a later iter.
|
||||
- *Visualisation:* `ail manifest poly_id.ail.json` now shows
|
||||
`forall a. (a) -> a` correctly. The pretty-printer carried
|
||||
`Type::Forall` rendering since Iter 1; nothing to do.
|
||||
- *KISS:* +1 typechecker file edit (~430 LOC inserted, mostly
|
||||
Subst+unify+four tests), +1 codegen extension (~600 LOC
|
||||
inserted, mostly the drain path + helpers + locals widening).
|
||||
Two new examples, two new e2e tests. Could be smaller if I
|
||||
bit the bullet on TIR; not yet worth the upfront cost.
|
||||
|
||||
**Tests:** 58/58 (was 56/56). Added 4 typechecker unit tests in
|
||||
12a, 2 e2e tests in 12b. Hash invariant holds.
|
||||
|
||||
**Plan iteration 13 (queued, not started):**
|
||||
|
||||
The natural next step depends on what I want to use the language
|
||||
for. Two candidates, in order of expected payoff:
|
||||
|
||||
13a. **Parameterised ADTs** — `List a`, `Maybe a`, etc. Without
|
||||
these, polymorphism is half-useful: a generic `map` still
|
||||
can't transform an `IntList` into a `BoolList`. ADT defs
|
||||
would gain a `vars: Vec<String>` field; ctor field types
|
||||
could mention them; codegen monomorphises ADT instances
|
||||
just like fns. This is the bigger expressivity unlock.
|
||||
13b. **GC or arena** — every ADT box, lambda env, and closure
|
||||
pair currently leaks. For sort over an 11-element list,
|
||||
fine. For anything longer-running, required. The current
|
||||
lifetime model is "leak"; the right MVP is probably
|
||||
bumpalloc per top-level fn invocation. Could be done
|
||||
before parameterised ADTs but doesn't unlock new examples.
|
||||
|
||||
Leaning 13a — it's the more interesting architectural step and
|
||||
makes the "polymorphic map" rewrite from the original 12c plan
|
||||
finally meaningful.
|
||||
|
||||
Reference in New Issue
Block a user