design: 22a — Decision 11 typeclasses (Haskell-lite, monomorphised, coherent)
This commit is contained in:
+312
@@ -1446,6 +1446,318 @@ materialises with a real workload.
|
||||
- **Does not introduce regions.** Regions were considered and
|
||||
rejected; see "Why not other memory models" above.
|
||||
|
||||
## Decision 11: typeclasses — Haskell-lite, monomorphised, coherent
|
||||
|
||||
**Committed 2026-05-09 (Iter 22a), as the design pass that gates
|
||||
22b implementer work. Codified after the Feature-acceptance
|
||||
criterion (this document, above) was committed; the criterion is
|
||||
the explicit basis for the choices below.**
|
||||
|
||||
AILang ships typeclasses to compress a real LLM-author redundancy:
|
||||
without them, every comparable function must be written per-type
|
||||
(`int_eq`, `string_eq`, `bool_eq`, `int_show`, `string_show`, …). With
|
||||
typeclasses behind a monomorphising compiler, the LLM author writes
|
||||
one signature with a class constraint and one method per concrete
|
||||
type, and the compiler emits the same machine code as the per-type
|
||||
version. No runtime cost, no dictionary passing, no vtables.
|
||||
|
||||
**Choice.** A deliberately narrow typeclass design — narrower than
|
||||
Haskell, narrower than Rust traits — calibrated to what an LLM
|
||||
author naturally produces. Five semantic axes are committed:
|
||||
|
||||
1. **Scope.** Multi-method, single-parameter, optional defaults,
|
||||
single-superclass relation. No multi-param classes, no
|
||||
functional dependencies, no associated types.
|
||||
2. **Constraints in signatures.** Explicit and mandatory. A
|
||||
function that calls a class method must declare the constraint
|
||||
in its `forall` block. No constraint inference.
|
||||
3. **Resolution.** Orphan-free coherence. An `instance C T` may be
|
||||
declared only in the module of `C` or in the module of `T`.
|
||||
Resolution is global type-directed against a workspace-built
|
||||
registry; coherence makes the lookup unambiguous.
|
||||
4. **Defaults.** Opt-in via an explicit `default` keyword in the
|
||||
class body. Methods without `default` are abstract-required;
|
||||
methods with `default` may be overridden or inherited per
|
||||
instance.
|
||||
5. **Class-parameter kind.** Concrete types only (kind `*`). No
|
||||
higher-kinded class params; `Functor`/`Monad`-style abstractions
|
||||
over type constructors are not expressible. The LLM-natural
|
||||
pattern is `List.map` / `Tree.map` as separate functions per
|
||||
type, which monomorphisation handles directly.
|
||||
|
||||
The five axes follow from the Feature-acceptance criterion: each
|
||||
rejected mechanism (multi-param, higher-kinded, FunDeps, assoc
|
||||
types) is one an LLM author does not unprompted produce.
|
||||
|
||||
### Form-A schema (the JSON authoring surface)
|
||||
|
||||
Three additive schema extensions; no existing module becomes invalid.
|
||||
|
||||
**`ClassDef`** — top-level definition kind, declares a class:
|
||||
|
||||
```
|
||||
{ "kind": "ClassDef",
|
||||
"name": "Show",
|
||||
"param": "a",
|
||||
"superclass": null,
|
||||
"methods": [
|
||||
{ "name": "show",
|
||||
"type": { /* full FnSig over `a`, with mode annotations */ },
|
||||
"default": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`superclass` is either `null` or `{ "class": <name>, "type": "a" }`
|
||||
where `"a"` MUST be the same identifier as the class's own `param`.
|
||||
`default` is either `null` (method is abstract-required at instance
|
||||
sites) or an AST body (method is optional with the body as fallback).
|
||||
|
||||
**`InstanceDef`** — top-level definition kind, declares an instance:
|
||||
|
||||
```
|
||||
{ "kind": "InstanceDef",
|
||||
"class": "Show",
|
||||
"type": "Int",
|
||||
"methods": [
|
||||
{ "name": "show", "body": <AST> }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`type` is a concrete type expression, never the class param. `methods`
|
||||
contains the bodies for every required method plus optional overrides
|
||||
of default-bearing methods.
|
||||
|
||||
**`FnDef.type` extension** — the existing FnSig schema gains a
|
||||
`constraints` sibling field next to `forall`:
|
||||
|
||||
```
|
||||
"type": {
|
||||
"forall": ["a"],
|
||||
"constraints": [{ "class": "Show", "type": "a" }],
|
||||
"type": ["Fn", [...], "String"]
|
||||
}
|
||||
```
|
||||
|
||||
When `forall` is empty, `constraints` is also empty (and may be
|
||||
omitted for hash stability of pre-22a definitions). Each constraint's
|
||||
`type` field MUST reference a type variable bound by the surrounding
|
||||
`forall`.
|
||||
|
||||
**Method invocation sites do NOT get a new node.** A call to a class
|
||||
method is a normal `Call` node; resolution against a class is the
|
||||
typechecker's job, not the parser's. The LLM author writes `show x`
|
||||
exactly as for any free function.
|
||||
|
||||
### Resolution and monomorphisation
|
||||
|
||||
**Constraint collection (per function body).** During typechecking
|
||||
of a body, each method call generates a residual constraint of shape
|
||||
`<Class> <Type>` where `<Type>` may still contain type variables.
|
||||
After local typechecking, residual constraints are checked against
|
||||
the function's declared constraints (modulo α-conversion and modulo
|
||||
auto-expansion through superclasses; see below). Any residual not
|
||||
covered by declared constraints fires `MissingConstraint`.
|
||||
|
||||
**Instance registry (workspace-global).** At workspace load (see
|
||||
`crates/ailang-core/src/workspace.rs`), all `InstanceDef` nodes
|
||||
across all reachable modules are collected into a registry keyed by
|
||||
`(class-name, canonical-hash-of-instance-type)`. Registry build
|
||||
performs three checks:
|
||||
|
||||
- **Coherence.** Each instance's module must be either the class's
|
||||
defining module or the instance type's defining module. Otherwise
|
||||
→ `OrphanInstance`.
|
||||
- **Uniqueness.** No two entries share a key. Otherwise →
|
||||
`DuplicateInstance`.
|
||||
- **Method completeness.** Each instance specifies every required
|
||||
(non-default) method of its class. Otherwise → `MissingMethod`.
|
||||
|
||||
Registry build is a one-time-per-build pass that fires before any
|
||||
typechecking. Its errors are workspace-load errors, not per-call-site
|
||||
errors.
|
||||
|
||||
**Resolution at call sites with concrete types.** When the typechecker
|
||||
sees a method call where every type variable in the constraint is
|
||||
substituted to a concrete type, it queries the registry. Hit →
|
||||
resolved. Miss → `NoInstance`.
|
||||
|
||||
**Resolution at polymorphic call sites.** When type variables are
|
||||
still free, the constraint propagates into the surrounding function's
|
||||
constraint context — which the user MUST have declared explicitly
|
||||
(per axis 2). No constraint is implicitly hoisted.
|
||||
|
||||
**Monomorphisation (post-typecheck, pre-codegen).** A pass between
|
||||
typechecking and codegen replaces every resolved class-method call
|
||||
with a call to a synthesised monomorphic `FnDef`. For each unique
|
||||
`(method, concrete-type)` pair encountered, the pass:
|
||||
|
||||
1. Synthesises a top-level `FnDef` named deterministically from the
|
||||
method name and the canonical hash of the instance type. Body is
|
||||
the resolved instance method (or default body), with the class
|
||||
parameter substituted to the concrete type.
|
||||
2. Caches the synthesised def by `(method, type-hash)` so the same
|
||||
pair is not emitted twice.
|
||||
3. Rewrites the original `Call` to target the synthesised name.
|
||||
|
||||
After this pass, the IR contains no class machinery — only ordinary
|
||||
monomorphic functions and direct calls. Codegen sees no difference
|
||||
between a hand-written `show_int` and a synthesised `show@Int`.
|
||||
|
||||
**No runtime dispatch, no dictionary passing.** The monomorphisation
|
||||
pass is the ONLY mechanism for class-method calls. A call that
|
||||
cannot be monomorphised — for instance, because a constraint remains
|
||||
unresolved at the entry point — is a static error, not a runtime one.
|
||||
This is the LLVM-friendly form and is consistent with Decision 10's
|
||||
performance commitment.
|
||||
|
||||
### Defaults and superclasses
|
||||
|
||||
**Defaults.** A `ClassDef.methods[i].default` is either `null` (the
|
||||
method is required at instance level) or an AST body. When an
|
||||
`InstanceDef` does not specify a method that has a default, the
|
||||
typechecker uses the default body for that instance, with the class
|
||||
param substituted to the instance type. Default bodies may call
|
||||
other methods of the same class (the canonical example is
|
||||
`default ne x y = not (eq x y)`); the called methods resolve at
|
||||
monomorphisation time against the same instance.
|
||||
|
||||
**Superclasses.** A `ClassDef.superclass` of `{ "class": "Eq",
|
||||
"type": "a" }` declares that any `instance C T` requires a
|
||||
corresponding `instance Eq T` to exist. The check fires at
|
||||
workspace-load time: for each `InstanceDef` whose class declares a
|
||||
superclass, the registry is queried for the matching superclass
|
||||
instance. Missing → `MissingSuperclassInstance`.
|
||||
|
||||
**Superclass auto-expansion in constraint contexts.** When a function
|
||||
declares `Ord a` as a constraint, the typechecker treats the constraint
|
||||
context as `{ Ord a, Eq a }` for the purposes of `MissingConstraint`
|
||||
checking. This is the one deviation from "alles sichtbar": the
|
||||
extension is anchored in the class's own `superclass` field, so the
|
||||
relation is schema-visible at the class declaration even when the
|
||||
constraint at the function site reads `Ord a` alone.
|
||||
|
||||
Superclass chains are linear (single-superclass relation per class)
|
||||
and auto-expansion closes transitively across the chain.
|
||||
|
||||
**No `deriving`.** 22a does not auto-derive instances. `instance Eq
|
||||
MyType` must be written by hand. Auto-derivation may land in a future
|
||||
iteration if the Feature-acceptance criterion holds for it; that
|
||||
discussion is out of scope here.
|
||||
|
||||
### Diagnostic categories
|
||||
|
||||
The typeclass layer introduces three families of diagnostics. Exact
|
||||
wording is fixed in 22b; the categories and their triggers are:
|
||||
|
||||
**Workspace-load (registry-build) diagnostics:**
|
||||
|
||||
- `OrphanInstance` — instance is not in class's or type's module.
|
||||
- `DuplicateInstance` — two instances match the same key.
|
||||
- `MissingSuperclassInstance` — superclass instance absent.
|
||||
- `MissingMethod` — instance omits a required method.
|
||||
- `OverridingNonExistentMethod` — instance specifies a method not in
|
||||
the class.
|
||||
- `MethodNameCollision` — same method name across two in-scope
|
||||
classes, or between a class method and a top-level function.
|
||||
|
||||
**Class-schema diagnostics** (validation of class declarations):
|
||||
|
||||
- `KindMismatch` — class param appears in applied position in any
|
||||
method signature (e.g., `f a` where `f` is the class param).
|
||||
- `InvalidSuperclassParam` — superclass `type` differs from the
|
||||
class's own `param`.
|
||||
- `ConstraintReferencesUnboundTypeVar` — a constraint mentions a
|
||||
type variable not bound by the surrounding `forall`.
|
||||
|
||||
**Typecheck diagnostics** (per function body):
|
||||
|
||||
- `MissingConstraint` — body's residual constraint is not covered
|
||||
by declared (and superclass-expanded) constraints.
|
||||
- `NoInstance` — fully concrete constraint has no registry entry.
|
||||
|
||||
There is no `AmbiguousInstance` diagnostic. Coherence (W2) makes
|
||||
every `(class, type)` key globally unique; resolution is therefore
|
||||
deterministic by construction.
|
||||
|
||||
### What 22a explicitly does NOT support
|
||||
|
||||
Each of the following is rejected by either schema (parser cannot
|
||||
express the construct) or by an enumerated diagnostic. The
|
||||
combination of axis-1 (single-param, no FunDeps, no assoc) and
|
||||
axis-5 (kind `*` only) covers the space.
|
||||
|
||||
- **Multi-parameter classes** (`class Foo a b where ...`). Schema
|
||||
rejects: `ClassDef.param` is a string, not a list.
|
||||
- **Higher-kinded class params** (`class Functor f`). Diagnostic
|
||||
S1 (`KindMismatch`) at class declaration if any method applies
|
||||
the param.
|
||||
- **Higher-rank polymorphism** (`forall a. (forall b. b -> b) -> a`).
|
||||
Already rejected at parse time per the typeclass-conversation
|
||||
rationale recorded in JOURNAL 2026-05-09; constraint-bearing
|
||||
signatures inherit that prohibition.
|
||||
- **Existential / dyn dispatch** (`exists a. Show a => a`,
|
||||
heterogeneous lists like `[Show]`). Schema does not express
|
||||
existentials. The LLM-natural alternative is sum types.
|
||||
- **Associated types** (`class Container c where type Element c`).
|
||||
Schema does not express type-level methods.
|
||||
- **Functional dependencies** (`class Convert a b | a -> b`). Required
|
||||
only for multi-param classes; entails by axis 1.
|
||||
- **`deriving` / auto-derivation** (`data Foo = ... deriving (Eq)`).
|
||||
Future iteration, gated separately on Feature-acceptance.
|
||||
- **Numeric literal defaulting.** `1` does not auto-resolve to `Int`
|
||||
under an ambiguous `Num a` constraint. Bare polymorphic literals
|
||||
with multiple satisfying instances fire `NoInstance` with a hint
|
||||
to annotate. The LLM-author writes `1 : Int` or `1 : Float`.
|
||||
- **Orphan-with-warning mode.** Coherence is hard. `OrphanInstance`
|
||||
is always an error, never a warning. The corollary `--allow-orphans`
|
||||
flag is not provided.
|
||||
|
||||
### Prelude (built-in) classes
|
||||
|
||||
22a/22b ship a fixed Prelude consisting of three classes: **`Show`,
|
||||
`Eq`, `Ord`**, with instances for the four primitive types `Int`,
|
||||
`Float`, `Bool`, `String`. `Ord` declares `Eq` as its superclass.
|
||||
|
||||
`print x` is rewired through `Show.show`: at codegen, the existing
|
||||
print primitive routes its argument through the resolved `show@T`.
|
||||
This is the one operator-routing change in 22b.
|
||||
|
||||
`==`, `<`, `<=`, `>`, `>=` REMAIN primitive operators. Class methods
|
||||
are accessed by name (`eq x y`, `lt x y`, …), not via these operators.
|
||||
Routing operators through classes is deliberately deferred — it would
|
||||
require migrating every existing fixture and would risk firing the
|
||||
bench gate. A future iteration may add operator routing once the
|
||||
typeclass layer has settled and the bench corpus is stable.
|
||||
|
||||
`Num` is NOT in the 22a/22b Prelude. Arithmetic operators (`+`, `-`,
|
||||
`*`, `/`) stay primitive and per-type. The LLM-natural pattern of
|
||||
distinct `Int` and `Float` arithmetic is preserved; class-based
|
||||
numeric overloading would invoke literal-defaulting which axis-7
|
||||
already excluded.
|
||||
|
||||
### What this decision does NOT commit to
|
||||
|
||||
- **Operator routing.** `==`, `<`, etc. stay primitive in 22a/22b.
|
||||
Class-routing operators is a future-iteration option, not a
|
||||
Decision-11 commitment.
|
||||
- **Form-B (prose) projection of class/instance.** The prose
|
||||
renderer for the new schema nodes is 22b implementer scope; the
|
||||
form is not pinned in 22a.
|
||||
- **Specific monomorphised-symbol naming format.** The naming is
|
||||
deterministic from `(method, type-hash)`; the exact textual form
|
||||
is fixed in 22b alongside the existing mangling scheme.
|
||||
- **Mode annotations on class methods.** Class method signatures
|
||||
ARE full FnSigs and DO carry mode annotations per Decision 10;
|
||||
the convention for default modes (typically `borrow` for
|
||||
read-only methods like `show`, `eq`) is settled in 22b.
|
||||
- **Number of Prelude classes.** Three classes (Show, Eq, Ord) is
|
||||
the 22b commitment. Adding `Num`, `Functor`-substitutes
|
||||
(per-type), or domain-specific classes is gated on the Feature-
|
||||
acceptance criterion at the time of proposal.
|
||||
|
||||
## Mangling scheme (Iter 5c)
|
||||
|
||||
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
|
||||
|
||||
Reference in New Issue
Block a user