iter design-md-rolesplit.1 (DONE 9/9): DESIGN.md -> design/ ledger role-split

The 3020-line docs/DESIGN.md is replaced by the design/ ledger:
design/INDEX.md (sole addressable spine, typed Contracts+Models tables,
polymorphic links — prose file OR authoritative source //!), 14
design/contracts/*.md test-linked invariants + 3 source-link-only
contracts (mangling/env-construction/qualified-xref, no prose file —
code is SoT), 5 design/models/*.md whitepapers, and
docs/journals/2026-05-19-design-decision-records.md (the
relitigation-guard archive — every why/rejected/does-not-do/rollback/
empirical ### moved out at ###-granularity). Clean cut: git rm
docs/DESIGN.md, no stub.

RED-first crates/ailang-core/tests/design_index_pin.rs — the 4-clause
anti-regrowth spine (DESIGN.md-gone / every-INDEX-link-resolves /
every-contract-names-a-resolvable-ratifier /
contracts-carry-no-decision-record-prose) — demonstrably RED before,
GREEN after. Build-atomic by task ordering: design_schema_drift.rs's
include_str! (the only compile-time consumer) retargeted to
design/contracts/data-model.md BEFORE the deletion; its
## Data model/## Pipeline slicer dropped (a simplification the split
enables). 2 NoInstance diagnostics + 2 lockstep E2Es retargeted to
design/contracts/{float-semantics,typeclasses}.md. ~12 agent reading
lists + 5 SKILL bodies + CLAUDE.md + skills/README.md + ~25
code/C/.ail/spec comment xrefs retargeted; OQ7 dangling 'Iter 13b'
cite deleted (no forward target — a pointer would be fiction).
honesty-rule.md rewritten so the rule names the new home
(rationale->journals), resolving the recon-found internal
contradiction; the two docs_honesty_pin.rs:70,72 pinned phrases kept
verbatim+contiguous.

Boss-verified independently: cargo test --workspace 646 passed /
0 failed; design_index_pin 4/4; acceptance grep CLEAN of live
DESIGN.md refs (residuals = only the spec-mandated clause-4
deletion-enforcer). 2 DONE_WITH_CONCERNS routed to the mandatory
milestone-close audit: (a) str-abi.md:23 '(iter str-concat,
2026-05-13)' provenance stamp trips advisory architect_sweeps Sweep-1
— Boss-confirmed byte-identical to DESIGN.md@deeffb1:2062-2065, a
faithfully-migrated PRE-EXISTING anchor (regexes verbatim, only path
retargeted), NOT split-introduced — RATIFY-or-tidy at audit; (b) a
now stale-direction intra-prose 'see Str ABI below' cross-ref in
float-semantics.md — audit-adjudication candidate. Plan defect noted:
Task 9 Step 4's verbatim acceptance grep used a ^./ anchor not
matching the system's grep -rIn output; substance re-verified CLEAN.

Spec grounding-check PASS x2. Journals INDEX + decision-records
pointer appended (Boss-only).
This commit is contained in:
2026-05-19 13:04:22 +02:00
parent deeffb1872
commit 176821c2e7
76 changed files with 3707 additions and 3340 deletions
+319
View File
@@ -0,0 +1,319 @@
# Typeclasses
## 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": "class",
"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": "instance",
"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 older 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.
## Cross-module references in synthesised bodies
The unified mono pass (per Decision 11's milestone-23.4 reorganisation)
synthesises mono symbols for polymorphic free fns and class-method
instances in the symbol's owner module — e.g. `print__Int` lives in
`prelude` (because `print` is defined in the prelude), but a
user-ADT call site `print (MkIntBox 7)` causes synthesis of
`prelude.print__IntBox` whose body references
`show_user_adt.show__IntBox` (the user instance lives in the
user-defining module per Decision 11 coherence). The synthesised body
crosses a module boundary the source template did not.
Three invariants make this work, all installed in milestone 24's iter
24.3 and worth keeping load-bearing:
1. **`MonoTarget::FreeFn::type_args` carries canonical types
post-collection.** At every site where `subst.apply(m)` produces a
concrete substitution that enters `MonoTarget::FreeFn::type_args`
(`crates/ailang-check/src/mono.rs::collect_mono_targets` and
`::collect_residuals_ordered`), the resolved `Type` must be passed
through `Registry::normalize_type_for_lookup(caller_module, &t)`
before being pushed. The downstream synthesised body's Phase 3
rewrite cursor (which runs in the OWNER module's context, not the
caller's) keys lookups in `Registry::entries` by the canonical
qualified form; a bare type-con reference at the type_args layer
silently drops the cross-module mono-symbol synthesis and leaves a
bare `Var "show"` post-mono that codegen later rejects with
`unknown variable`.
2. **Post-mono synthesised body cross-module references may bypass
the source template's `import_map`.** `prelude` does not import
user modules (the auto-injection runs one-way: user workspaces
import prelude, never the inverse). But a synthesised body for
`prelude.print__<UserType>` references `<user_module>.show__<UserType>`,
created by mono — not by the prelude source. Codegen's
cross-module name-resolution must accept this: at
`crates/ailang-codegen/src/lib.rs::resolve_top_level_fn` (Var-
resolution), `::lower_app`'s cross-module call arm, and
`::synth_with_extras`'s Var arm, the resolution first tries the
current module's `import_map`, then falls back to a direct
`module_user_fns` / `module_def_ail_types` lookup against the
prefix. Both ends were independently typechecked under their own
module contexts before mono ran; the cross-module reference is a
post-mono construct, not a source-language one.
3. **FreeFnCall synth pushes one residual per declared forall-
constraint.** At `crates/ailang-check/src/lib.rs`'s synth Var arm
for the `prefix.suffix` free-fn path, when the type is a
`Type::Forall { vars, constraints, body }`, instantiation produces
fresh metavars for the `vars` AND pushes one `ResidualConstraint`
per entry in `constraints` (with rigid vars substituted by the
freshly-generated metavars). The downstream discharge loop at
`check_fn`'s post-synth phase resolves each residual against the
workspace registry; if no instance satisfies the residual at the
unified concrete type, the `NoInstance` diagnostic fires at
typecheck (correctly), not at codegen (confusingly). Without the
residual push, milestone-23-shape negative cases (e.g. `print f`
where `f : Int -> Int`) silently typecheck and surface as
`unknown variable: show` from codegen instead of the right
typecheck-phase NoInstance Show.
The three invariants are lockstep partners: invariant (1) creates the
cross-module reference, invariant (2) makes codegen able to resolve
it, invariant (3) makes the typecheck-time discharge fire correctly
when no instance exists. A future refactor that loosens any one of
the three breaks the user-ADT trajectory; the test pins at
`crates/ail/tests/codegen_import_map_fallback_pin.rs` (invariant 2),
`crates/ail/tests/polyfn_dot_qualified_branch_pin.rs` (invariant 3
+ lockstep), and the existing `crates/ail/tests/show_user_adt`
fixture (full trajectory) collectively protect the contract.
## 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`.** AILang does not auto-derive instances. `instance Eq
MyType` must be written by hand.
## Diagnostic categories
The typeclass layer introduces three families of diagnostics. Exact
wording is fixed; 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` was retired at iter mq.3. Cross-class method
sharing is now structurally legal; ambiguity surfaces at the call
site via `AmbiguousMethodResolution` or, for class-fn name overlap,
via the `class-method-shadowed-by-fn` warning. See §"Method
dispatch" below.)
**Class-schema diagnostics** (validation of class declarations):
- `InvalidSuperclassParam` — superclass `type` differs from the
class's own `param`.
- `ConstraintReferencesUnboundTypeVar` — a constraint mentions a
type variable not bound by the surrounding `forall`.
A class param appearing in applied position (e.g., `f a` where `f`
is the class param) is rejected earlier by the canonical-form
validator as `BareCrossModuleTypeRef``f` is a bare,
non-primitive name not declared as a `TypeDef` in the owning
module — so the dedicated `KindMismatch` diagnostic was retired
at iter ctt.3.
**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.
mq.2 adds an optional `candidate_classes` field surfacing the
multi-candidate set when the bare-method dispatch path's filter
collapses to zero registry survivors.
- `AmbiguousMethodResolution` (mq.2) — a monomorphic `Term::Var`
call site survives both type-driven and constraint-driven filters
with more than one candidate class. LLM-author writes the explicit
qualifier form `<module>.<Class>.<method>` to disambiguate.
- `UnknownClass` (mq.2) — an explicit class qualifier in
`Term::Var.name` names a qualified class that is not in the
workspace's candidate-class index for the method.
- `class-method-shadowed-by-fn` (mq.3, warning) — a `Term::Var`
resolved via fn lookup precedence (locals → caller-module-fn →
imported-fn) while a class method of the same name also exists
in the workspace. Fn resolution proceeds; the warning surfaces
the shadow so the LLM-author can disambiguate via explicit
class-qualified call if the shadow was unintentional.
There is no `AmbiguousInstance` diagnostic at the registry level —
coherence (`DuplicateInstance` at registry build, W2) makes
per-`(class, type)` ambiguity structurally impossible. Cross-class
method ambiguity is a separate concern resolved at the call site
via `AmbiguousMethodResolution` (see §"Method dispatch" below).
## Method dispatch
Post-mq.3, dispatch is two-mode:
**Polymorphic call sites** (inside a fn body with `forall` +
constraint set): the constraint names the class via the qualified
`Constraint.class` field (canonical-form per mq.1). Synth's
residual carries the class name directly; constraint-discharge at
fn-body-end matches against the workspace registry by
`(class, type_hash)` key.
**Monomorphic call sites**: synth consults the workspace-flat
`Env.method_to_candidate_classes: BTreeMap<MethodName,
BTreeSet<QualifiedClassName>>` index, then runs the 5-step
dispatch rule:
1. Parse the `Term::Var.name` for an optional class qualifier
(last-dot-segment is the method name; everything before is the
qualified class).
2. If `method_to_candidate_classes` has no entry for the method
name, fall through to the existing Var-arm branches (free fn
lookup, dot-qualified cross-module).
3. Qualifier present: filter candidates to the named class. Empty
result fires `UnknownClass`. Singleton survivor proceeds.
4. Qualifier empty (bare-method form): singleton candidate proceeds
directly; multiple candidates yield a multi-candidate residual
for discharge-time refinement.
5. At discharge, refinement runs: concrete `type_` filters
candidates via the workspace registry; rigid-var `type_` filters
via the active fn's declared constraints
(`env.active_declared_constraints`). Single survivor discharges;
multiple survivors fire `AmbiguousMethodResolution` (concrete) or
`MissingConstraint` (rigid-var); zero survivors fire `NoInstance`
(concrete) or `MissingConstraint` (rigid-var).
The `method_to_candidate_classes` index is the load-bearing data
structure for this routing — its construction in `build_check_env`
inverts the per-module `class_methods` maps (themselves tuple-keyed
by `(qualified-class, method)` post-mq.3) to a workspace-flat
method-name-to-class-set map.
Class-fn collisions resolve at the call site, not at workspace load
time: the fn lookup precedence (locals → caller-module-fn →
imported-fn) runs ahead of the class-method branch. When both
sides have a match, the fn wins and a `class-method-shadowed-by-fn`
warning surfaces the shadow.
## Prelude (built-in) classes
Milestone 22 ships **no built-in Prelude classes**.
An earlier draft committed to a fixed Prelude (Show / Eq / Ord on the
primitives), but the implementation work to wire `int_to_str` as a
heap-allocated-string runtime primitive proved substantively
separable from the typeclass machinery itself, and the LLM-utility
case for primitive `Show` is weak (LLM-natural form is `int_to_str
x`, not `show x` through a single-instance class). The user-class
end-to-end path is the milestone's typeclass acceptance gate
(the user-defined-class fixture: `class Foo a` + `data IntBox` +
`instance Foo IntBox`); see `docs/specs/2026-05-09-22-typeclasses.md`
"Amendments" for the substantive rationale.
Milestone 23 amends the above: the prelude now ships the `Ordering`
ADT, the `Eq` and `Ord` classes, primitive `Eq Int/Bool/Str` and
`Ord Int/Bool/Str` instances, and the five polymorphic free-fn helpers
`ne` / `lt` / `le` / `gt` / `ge`. Operator routing through `Eq`/`Ord`
and `print`-rewire remain out of scope per their original substantive
reasons (bench rebaseline, milestone-24 dependency on the post-mq
dispatcher); `Show` itself ships in milestone 24. Float has neither
`Eq` nor `Ord` instance per §"Float semantics"; a polymorphic helper
invoked at Float fires `NoInstance` at typecheck with a Float-aware
diagnostic cross-referencing this section.
Milestone 24 amends the above further: the prelude ships `class Show
a where show : (a borrow) -> Str` and primitive `Show Int`,
`Show Bool`, `Show Str`, `Show Float` instances (iter 24.2). Float
**is** included in Show (unlike Eq/Ord) — IEEE-754 makes structural
equality and total ordering semantically dubious, but textual
representation of a Float is well-defined modulo the NaN-spelling
caveat in §"Float semantics". Each `Show <T>` instance body is a
single-application lambda invoking the corresponding runtime primitive
(`int_to_str`, `bool_to_str`, `str_clone`, `float_to_str`); no codegen
intercept is required. The polymorphic helper `print : forall a. Show
a => a -> () !IO` shipped in iter 24.3 with body
`\x -> let s = show x in do io/print_str s` (explicit let-binder for
heap-Str RC discipline per eob.1 Str carve-out). The let-binder is
structurally pinned by `crates/ail/tests/print_mono_body_shape.rs`.
Routing through `print` is the path for non-`Str` primitives;
`io/print_str` is the only built-in direct-output effect-op.
Ratified by: `crates/ail/tests/show_no_instance_e2e.rs`.