Files
AILang/docs/specs/0002-22-typeclasses.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

18 KiB
Raw Blame History

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 §14491759. 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 shipped 2026-05-09 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 shipped 2026-05-09 Monomorphisation pass — synthesised FnDefs from (method, type-hash) pairs, call rewriting, cache by key. Synthetic class+instance fixture for e2e validation. __ separator chosen over # for LLVM IR identifier legality. Synthetic fixture compiles, runs, correct stdout; bench gates green
22b.4a shipped 2026-05-09 Form-A parser+printer arms for ClassDef / InstanceDef; Type::Forall.constraints round-trip gap fix; round-trip skip-list retired (70 → 106 fixtures green). Round-trip green for every examples/*.ail.json; bench gates green
22b.4b dropped (post-22) Originally: Prelude module Show#Int + int_to_str C-runtime primitive. Removed from milestone-22 scope per Amendments §"22b.4b dropped" — substantively separable runtime work; LLM-utility for primitive Show is weak. (deferred)
22c shipped 2026-05-09 Single end-to-end fixture: class Foo a + data IntBox + instance Foo IntBox + main printing 42. Surfaced and fixed a mono-pass user-ADT bug as part of close. Fixture compiles, runs, correct stdout; bench gates 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 printShow.showshow@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_<module>_<def>. Recommendation: <method>#<typesurfacename> 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 <Name> <param> { <method> : <FnSig> } and instance <Class> <Type> { <method> = <body> } 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 <method>#<typesurfacename> for the mono-symbol naming format with the implementer free to choose. 22b.3 implemented <method>__<typesurfacename> 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 <method>__<typesurfacename>.

Hash-suffix form for compound types (<method>__<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 <Class> <type>)+ ) 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.