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.
18 KiB
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:
- Haskell-lite scope (multi-method, single-param, optional
defaults, single-superclass) — holds. LLM produces
class Eq a where eq, nenaturally;Ord extends Eqis the textbook case. Multi-param classes and HKTs remain LLM-distant. - Constraints explicit + mandatory — holds. The "alles sichtbar" line is project-zementiert; constraints visible at the function boundary is the LLM-author advantage.
- Orphan-free coherence — holds, with one documentation
note: primitives (Int/Float/Bool/String) have no defining
module —
instance MyClass Intmust therefore live in MyClass's module. Test fixturetest_22b1_orphan_thirdconfirms. Workspace-closed authoring is the right precondition. - Defaults via explicit
defaultkeyword — holds. LLM writesdefault ne x y = not (eq x y)more naturally than mixed class bodies à la Haskell. - Class-param kind
*only — holds. LLM-natural pattern isList.map/Tree.mapper 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-instanceduplicate-instancemissing-method
Workspace-load (registry-build) — 22b.2:
missing-superclass-instanceoverriding-non-existent-methodmethod-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(superclasstype≠ class's ownparam)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-constraintrecovery (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 42exercisingprint→Show.show→show@Int). Round-trip filter retires; allexamples/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:
- 22b.1 + 22b.2 + 22b.3 + 22b.4 + 22c JOURNAL entries committed.
- Audit suite (architect drift review against DESIGN.md +
bench/check.py+bench/compile_check.py+bench/cross_lang.py) green. - The 22c user-class fixture compiles, runs, and produces correct stdout — with no Prelude class involvement (ensures the path is independently exercised).
- Round-trip filter
test_22b1_*is removed; allexamples/*.ail.jsonpass 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 newint_to_strC-runtime primitive, codegen wiring for it, and an end-to-endshow 42fixture that prints42through 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):
-
No runtime primitive for
int_to_stryet. Show#Int's body needs to convert ani64to a heap-allocatedStr. 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.
-
Low LLM-utility for primitive Show. An LLM that needs to render an
Intwritesint_to_str x(a direct primitive call) more naturally thanshow 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). -
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:
++forStr— no concat operator incrates/ailang-check/src/builtins.rs::list().- Named-field record syntax
Person { name = "world" }— AILang'sdata/ctorform is positional ((ctor MkPerson Str)), not record-with-named-fields. printpolymorphic onShow— 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.