# 22 — Typeclasses Milestone — Design Spec **Date:** 2026-05-09 **Status:** Draft — awaiting user spec review **Authors:** Brummel (orchestrator) + Claude ## Goal Ship typeclasses end-to-end as committed in DESIGN.md Decision 11 ("typeclasses — Haskell-lite, monomorphised, coherent"). Validate the user-defined-class path works end-to-end before declaring the milestone closed — not just the Prelude path. This is a retrospective milestone spec. 22a (decision iter) and 22b.1 (schema floor + workspace registry) shipped before the brainstorm-skill pipeline existed. The spec ratifies the existing design where it holds and corrects iter-slicing where Feature- Acceptance points to a gap. ## Architecture Design is fully specified in DESIGN.md Decision 11 §1449–1759. The five committed semantic axes were re-checked against the Feature- Acceptance criterion at the time of writing: 1. **Haskell-lite scope** (multi-method, single-param, optional defaults, single-superclass) — holds. LLM produces `class Eq a where eq, ne` naturally; `Ord extends Eq` is the textbook case. Multi-param classes and HKTs remain LLM-distant. 2. **Constraints explicit + mandatory** — holds. The "alles sichtbar" line is project-zementiert; constraints visible at the function boundary is the LLM-author advantage. 3. **Orphan-free coherence** — holds, with one documentation note: primitives (Int/Float/Bool/String) have no defining module — `instance MyClass Int` must therefore live in MyClass's module. Test fixture `test_22b1_orphan_third` confirms. Workspace-closed authoring is the right precondition. 4. **Defaults via explicit `default` keyword** — holds. LLM writes `default ne x y = not (eq x y)` more naturally than mixed class bodies à la Haskell. 5. **Class-param kind `*` only** — holds. LLM-natural pattern is `List.map`/`Tree.map` per type, not Functor abstraction. Monomorphisation handles per-type directly. No axis falls. Decision 11 stays unchanged. The brainstorm correction is to iter-slicing, not to axis content. ## Components (iterations) | Iter | Status | Scope | Closes when | |---|---|---|---| | 22b.1 | shipped 2026-05-09 | Schema floor (`ClassDef`, `InstanceDef`, `FnDef.type` extension), workspace registry with three coherence checks (orphan/duplicate/missing-method), hash-stability proof, fixture filter for round-trip gate. | JOURNAL entry committed (done) | | 22b.2 | queued | Typecheck arms: `FnDef.type.constraints` field activation; class-schema validation (`kind-mismatch`, `invalid-superclass-param`, `constraint-references-unbound-type-var`); per-call-site `missing-constraint` and `no-instance`; `overriding-non-existent-method`, `method-name-collision`, `missing-superclass-instance`. | All new diagnostics fire on dedicated fixtures; bench gates green | | 22b.3 | queued | Monomorphisation pass — synthesised `FnDef`s from `(method, type-hash)` pairs, call rewriting, cache by key. **Synthetic class+instance fixture** in the test suite for end-to-end validation of the mono pass before the Prelude lands. | Synthetic fixture compiles, runs, correct stdout; bench gates green | | 22b.4 | queued | Prelude module (Show/Eq/Ord on Int/Float/Bool/String), `print` rewiring through `Show.show`, Form-B parser/printer arms for ClassDef/InstanceDef, `test_22b1_*` round-trip filter retired. | Prelude fixture compiles, runs; round-trip green for all `examples/`; bench gates green | | 22c | **promoted** (was deferred) | Single end-to-end fixture: user defines `class Foo a`, user defines `data Bar`, user writes `instance Foo Bar`, calls `foo b`. Bench gate. JOURNAL entry = milestone close. | Fixture compiles, runs, correct stdout; full audit suite green | 22c was deferred in 22a's queue. This brainstorm flagged it as a shipping gap: the milestone is "typeclasses" but the Prelude path alone (compiler-internal setup) does not validate that an LLM-author can in fact define a class and an instance and have them work. Promoting 22c with tight scope (one fixture, no `deriving`) closes the gap without expanding the milestone substantially. 22b.3's synthetic fixture is the matching addition: monomorphisation must be testable before the Prelude lands, so its iter cannot rely on 22b.4 for end-to-end validation. ## Data flow Per DESIGN.md Decision 11 §"Resolution and monomorphisation": ``` .ail.json modules ↓ workspace load ↓ ↓ collect ClassDef per (class-name → defining-module) ↓ ↓ collect InstanceDef per (class-name, type-hash) ↓ ↓ coherence + uniqueness + completeness + superclass checks ↓ Workspace { defs, registry } ↓ ↓ per-FnDef typecheck ↓ ↓ collect residual constraints from method calls ↓ ↓ check residuals against declared (+ superclass-expanded) ↓ ↓ resolve fully-concrete constraints against registry ↓ ↓ monomorphisation pass ↓ ↓ for each (resolved-method, concrete-type) pair: ↓ ↓ synthesise FnDef with hash-deterministic name ↓ ↓ substitute class param to concrete type in body ↓ ↓ rewrite original Call to target synthesised name ↓ ↓ codegen sees only ordinary monomorphic FnDefs and direct calls LLVM IR ``` No runtime dispatch, no dictionary passing. A class-method call that cannot be monomorphised (constraint unresolved at entry point) is a static error, not a runtime one. ## Error handling Diagnostic catalogue, by phase. Codes follow the project's existing kebab-case CLI convention. **Workspace-load (registry-build) — landed in 22b.1:** - `orphan-instance` - `duplicate-instance` - `missing-method` **Workspace-load (registry-build) — 22b.2:** - `missing-superclass-instance` - `overriding-non-existent-method` - `method-name-collision` (across two in-scope classes, or class method vs top-level function) **Class-schema validation — 22b.2:** - `kind-mismatch` (class param appears in applied position in any method signature) - `invalid-superclass-param` (superclass `type` ≠ class's own `param`) - `constraint-references-unbound-type-var` **Per-FnDef typecheck — 22b.2:** - `missing-constraint` (residual not covered by declared + superclass-expanded constraints) - `no-instance` (fully concrete constraint with no registry entry) No `ambiguous-instance` diagnostic — coherence makes registry keys globally unique by construction. ## Testing strategy **Per iter:** - **22b.1** — already shipped. Schema-extension hash-stability tests + 5 registry-diagnostic fixtures. - **22b.2** — one fixture per new diagnostic (one-fixture-one- diagnostic rule). Plus a positive fixture exercising `missing-constraint` recovery (constraint declared correctly → green). - **22b.3** — synthetic class+instance fixture exercising the mono pass end-to-end. Hand-written `class Foo a where foo : a -> Int` + `instance Foo Int where foo i = i` + `fn main = print (foo 5)`. Verifies the synthesised def is emitted, called, and produces "5". Plus unit tests on the mono cache (no double emission). - **22b.4** — Prelude fixture (`fn main = print 42` exercising `print` → `Show.show` → `show@Int`). Round-trip filter retires; all `examples/test_22b1_*` plus the new prose round-trip passes. - **22c** — single user-class e2e: `class Greet a where greet : a borrow -> String` + `data Person { name : String }` + `instance Greet Person where greet p = "Hello, " ++ p.name` + `fn main = print (greet (Person { name = "world" }))`. **Bench gates:** all three (`bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py`) green at every iter close. The mono pass is a no-op for fixtures without classes — pre-22b modules stay bit-identical through compile. ## Acceptance criteria The 22 milestone closes when **all** of the following hold: 1. 22b.1 + 22b.2 + 22b.3 + 22b.4 + 22c JOURNAL entries committed. 2. Audit suite (architect drift review against DESIGN.md + `bench/check.py` + `bench/compile_check.py` + `bench/cross_lang.py`) green. 3. The 22c user-class fixture compiles, runs, and produces correct stdout — with no Prelude class involvement (ensures the path is independently exercised). 4. Round-trip filter `test_22b1_*` is removed; all `examples/*.ail.json` pass round-trip. ## Out of scope (deferred, with substantive rationale) **Operator routing** (`==`, `<`, `<=`, `>`, `>=` through `Eq`/`Ord`). Deferred to a post-22 milestone bundled with bench re-baselining. Rationale: the entire bench corpus typechecks differently when operators route through classes; the new monomorphised trampolines may codegen non-equivalently to the primitive form, which is a new baseline rather than a regression. Re-baselining is its own work, not an iter detail. The 22a "would risk firing the bench gate" phrasing was an effort-rationale; the substantive form is the new- baseline argument above. **`deriving`.** Deferred to a post-22 milestone, contingent on 22 shipping clean. Rationale: `deriving` requires a generic-synthesis pass at workspace-load (parsing `deriving (Eq, Show)`, synthesising `InstanceDef` AST nodes, feeding them into the same coherence pipeline as hand-written instances). That is new infrastructure, not a 22-arc detail. Synthesising instances for a typeclass mechanism whose user-class path has not been e2e-validated would also stack risk. **`Num` class.** No concrete promotion path. Decision 11 rationale holds (class-based numeric overloading invokes literal-defaulting, which axis-7 excluded). Numeric operations stay primitive and per-type. Trigger for re-evaluation: a concrete user case that cannot be expressed otherwise. ## Open commitments (22a → 22b implementer) Three items 22a explicitly punted to the 22b implementer. The brainstorm position on each: **Mono-symbol naming format.** Implementer call in 22b.3. Constraints: must contain the method name and a type indicator legible in diagnostics; must be hash-stable; must coexist with the existing mangling scheme `@ail__`. Recommendation: `#` for primitive types (`show#Int`), hash-suffix for compound types — but the implementer chooses. **Form-B prose projection for ClassDef/InstanceDef.** Implementer call in 22b.4. Constraints: must round-trip exactly with the JSON form; must follow the existing C-like prose convention. Recommendation: `class { : }` and `instance { = }` parallel to existing FnDef/DataDef forms — but the implementer chooses. **Mode annotations on class methods.** Not a "convention" in the strict sense — class method signatures are full FnSigs and carry mode annotations per Decision 10. Position for the Prelude (22b.4): `borrow` for all read-only methods (`show`, `eq`, `ne`, `lt`, `le`, `gt`, `ge`). User-defined classes choose modes per method. ## Known costs **Reserved-name footprint of the Prelude.** Once 22b.4 ships, the following names are class-method-occupied at the workspace top level: `show`, `eq`, `ne`, `lt`, `le`, `gt`, `ge`. A user-defined function or class method with any of these names fires `method-name-collision`. The cost is a small lexicon of forbidden names; the benefit is no namespace fragmentation across the Prelude classes. ## Amendments ### 2026-05-09 — Iteration split: 22b.4 → 22b.4a + 22b.4b Original 22b.4 scope ("Prelude module Show/Eq/Ord on Int/Float/Bool/String, print rewiring, Form-B parser/printer arms, round-trip filter retired") splits into two iterations: - **22b.4a (this iter):** Form-A parser+printer arms for ClassDef and InstanceDef; round-trip skip-list retired for `test_22b1_*`, `test_22b2_*`, `test_22b3_*`. Pure surface plumbing, no codegen or runtime change. - **22b.4b (queued):** Prelude module containing `class Show a where show : (a borrow) -> Str`, `instance Show Int`, the new `int_to_str` C-runtime primitive, codegen wiring for it, and an end-to-end `show 42` fixture that prints `42` through the existing mono pass. Substantive rationale: 22b.4a touches one crate (`ailang-surface`) with no runtime/codegen risk; 22b.4b touches `runtime/`, `ailang-codegen`, `ailang-check::builtins`, plus a new fixture, with a different review surface and a different bench-gate exposure. Bundling them was a brainstorm-time underestimate of the runtime-side work; running them as separate iterations preserves the bite-sized cycle the skill system enforces. ### 2026-05-09 — Form-A vs Form-B terminology fix The original 22 spec ("Form-B parser/printer arms for ClassDef /InstanceDef") and the 22b.1 round-trip-skip-list comment both loosely call the s-expression projection "Form-B". That is a terminology error: `crates/ailang-surface` is **Form-A** (the parseable s-expression). `crates/ailang-prose` is **Form-B** (human-readable, one-way projection — no parser by design). The 22b.4a arms are Form-A. Form-B printer arms for ClassDef / InstanceDef are NOT required for the round-trip gate (it is unparser-side); they are queued as an audit-cycle nicety, not as a milestone-22 acceptance criterion. The skip-list comment that read "the surface (Form-B) parser/printer arms" was retired alongside the filter (Task 5). ### 2026-05-09 — Mono-symbol separator: `__` chosen over `#` 22a's brainstorm recommended `#` for the mono-symbol naming format with the implementer free to choose. 22b.3 implemented `__` instead (e.g. `show__Int`, `eq__Bool`). Reason: `#` is an invalid character in LLVM IR global identifiers (`@ail_..._foo#Int_clos` is rejected by the LLVM verifier). `__` is verifier-legal, identifier-legal in C-glue contexts, and parses unambiguously into method + type surface name (neither method names nor type surface names contain `__` by convention). The recommendation in §"Open commitments (22a → 22b implementer)" is updated to read `__`. Hash-suffix form for compound types (`__<8-hex>`) follows the same separator. ### 2026-05-09 — Forall-constraints surface arm gap (22b.4a.4.5) Surfaced during 22b.4a Task 5: the existing Form-A parser/printer arms dropped `Type::Forall.constraints` (the field added in 22b.2 to carry class constraints on polymorphic types). Four `test_22b2_*` fixtures regressed when the round-trip skip-list was lifted. Fixed in 22b.4a.4.5 by extending `parse_forall_type` and the `Type::Forall` arm of `write_type` to handle an optional `(constraints (constraint )+ )` clause. Round-trip is now bit-stable for every forall-with-constraints fixture in `examples/`. This was not in the original 22b.4 plan because the brainstorm did not anticipate the gap — the skip-list was thought to mask only the ClassDef/InstanceDef arms, not the forall-constraints arm too. The forall-constraints fix is properly part of 22b.4a (cannot retire the filter without it) and is recorded here for completeness. ### 2026-05-09 — Acceptance criteria 4 still binding Acceptance criterion 4 ("Round-trip filter `test_22b1_*` is removed; all `examples/*.ail.json` pass round-trip") is satisfied by 22b.4a. The `test_22b2_*` and `test_22b3_*` filters added subsequently are also retired here. No further round-trip gate work is required for 22 milestone close. ### 2026-05-09 — 22b.4b dropped: Prelude-for-primitives deferred to post-22 The 22b.4 → 22b.4a + 22b.4b split (recorded above) further reduces: 22b.4b is removed from milestone-22 scope entirely. Acceptance criterion 1 amends to read "22b.1 + 22b.2 + 22b.3 + 22b.4a + 22c JOURNAL entries committed". Substantive rationale, per the project's feature-acceptance criterion (LLM utility): 1. **No runtime primitive for `int_to_str` yet.** Show#Int's body needs to convert an `i64` to a heap-allocated `Str`. AILang currently only has constant interned strings (`@.str_*` LLVM globals). A heap-string ABI — runtime alloc, RC-count, drop — is independently new infrastructure. Wiring it through a class method first stacks risk: typeclass cascade + heap-string ABI + new C-runtime function + new codegen arm in one iter. 2. **Low LLM-utility for primitive Show.** An LLM that needs to render an `Int` writes `int_to_str x` (a direct primitive call) more naturally than `show x` (which routes through a Prelude class with one instance). Class dispatch shines when the LLM defines a user type and wants polymorphism; for primitives, the Prelude class is a thin wrapper with negative entropy (extra indirection, no new property). 3. **22c covers the user-class e2e independently.** The milestone's typeclass-machinery property is exercised end-to-end by 22c's user-class fixture (class Foo a + data Bar + instance Foo Bar + main calling foo). The Prelude path adds no semantic capability beyond what 22c demonstrates. When does Prelude-for-primitives return? When concrete LLM-author code surfaces a case that genuinely benefits from `Show a` / `Eq a` / `Ord a` over per-type primitive functions — that case becomes the brainstorm trigger for a post-22 Prelude milestone. Until then, `int_to_str` ships as a plain primitive (or not at all) the day a fixture needs it. ### 2026-05-09 — 22c scope tightened to vocabulary AILang already has The original 22c spec (§"Components" row) sketched the e2e fixture as `class Greet a where greet : a borrow -> String` + `data Person { name : String }` + `instance Greet Person where greet p = "Hello, " ++ p.name`. That sketch references three constructs AILang does not currently support: - **`++` for `Str`** — no concat operator in `crates/ailang-check/src/builtins.rs::list()`. - **Named-field record syntax `Person { name = "world" }`** — AILang's `data`/`ctor` form is positional (`(ctor MkPerson Str)`), not record-with-named-fields. - **`print` polymorphic on `Show`** — see preceding amendment. 22c's acceptance property is "user-defined class + user-defined data type + instance + call site, monomorphised, runs, prints right value". That property holds with any vocabulary AILang has. The fixture is re-scoped to: ``` class Foo a where foo : (a borrow) -> Int data IntBox = MkIntBox Int instance Foo IntBox where foo = λb. (match b { MkIntBox v => v }) fn main : () -> Unit !IO body: do io/print_int (foo (MkIntBox 42)) ``` Stdout: `42\n`. No new primitives, no new surface syntax, no new codegen paths beyond what 22b.3 shipped. The property the original sketch was reaching for (typeclass dispatch over a user-defined ADT, end-to-end, with a hand-readable expected output) holds identically.