Third tranche, completing the honesty-rule sweep across the remaining
contracts. Same conservative bar: cut unbacked-verification claims,
change/deletion history, and forward-intent; keep present-state design
rationale.
- 0003: drop "sanitiser-verified" — no TSan/sanitiser test exists in
the tree, so the claim asserts a verification that does not happen.
The data-race-freedom property (argued from the non-atomic-but-never-
shared hot path + atomic-relaxed shared counter) stays.
- 0008: the `Type::Con.name` hash paragraph ("the tightening shifted
the hashes ... The new pins ... re-asserts the pre-tightening hashes")
-> present-state: which fixtures carry which hash shape and which test
pins each.
- 0013: drop "the former codegen-side fallback (at ..., and the
now-deleted `synth_with_extras`) is retracted" (deletion history; the
present fact is that codegen does not re-resolve, per the boundary);
"A future refactor that loosens any one of the four breaks ..." ->
present-tense statement that the four are load-bearing and each pinned.
- 0017: drop "a real cost surfaced by the `bench_closure_chain`
regression at the operator-routing-eq-ord milestone" (history) and
"the symmetric extensions are mechanical when the first such workload
appears" (forward-intent); keep the present-state allocation cost, the
pin, and the Int-only-asymmetry rationale.
All 18 contracts now reviewed against the code. Ledger pins green;
honesty sweep clean.
12 KiB
Typeclasses
The schema, registry, and diagnostic contract for the typeclass layer. The resolution/monomorphisation whitepaper lives at models/typeclasses; the call-site lookup rule at method dispatch; the built-in classes the prelude ships at prelude classes.
Form-A schema (the JSON authoring surface)
Three additive schema extensions; no existing module becomes invalid.
The canonical schema for ClassDef, InstanceDef, and the
FnDef.type constraints field lives in
Data model; this section is the typeclass-layer
narrative.
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 (see
models/typeclasses for the
resolution/monomorphisation algorithm) 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 the
orphan-free-coherence rule documented in
models/typeclasses). The synthesised
body crosses a module boundary the source template did not.
Three invariants make this work:
-
MonoTarget::FreeFn::type_argscarries canonical types post-collection. At every site wheresubst.apply(m)produces a concrete substitution that entersMonoTarget::FreeFn::type_args(crates/ailang-check/src/mono.rs::collect_mono_targetsand::collect_residuals_ordered), the resolvedTypemust be passed throughRegistry::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 inRegistry::entriesby 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 bareVar "show"post-mono that codegen later rejects withunknown variable. -
Post-mono synthesised body cross-module references are resolved once, in
lower_to_mir.preludedoes not import user modules (the auto-injection runs one-way: user workspaces import prelude, never the inverse). But a synthesised body forprelude.print__<UserType>references<user_module>.show__<UserType>, created by mono — not by the prelude source — so the call crosses a module boundary the source template did not. This reference is resolved at lowering time:crates/ailang-check/src/lower_to_mir.rs::classify_calleeresolves the callee against the workspace and emitsCallee::Static { module, fn_name, .. }carrying the resolved owner. Codegen consumes that identity off the MIRAppnode and does not re-resolve it, per the check→codegen boundary. 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. -
FreeFnCall synth pushes one residual per declared forall- constraint. At
crates/ailang-check/src/lib.rs's synth Var arm for theprefix.suffixfree-fn path, when the type is aType::Forall { vars, constraints, body }, instantiation produces fresh metavars for thevarsAND pushes oneResidualConstraintper entry inconstraints(with rigid vars substituted by the freshly-generated metavars). The downstream discharge loop atcheck_fn's post-synth phase resolves each residual against the workspace registry; if no instance satisfies the residual at the unified concrete type, theNoInstancediagnostic fires at typecheck (correctly), not at codegen (confusingly). Without the residual push, polymorphic-helper negative cases (e.g.print fwheref : Int -> Int) silently typecheck and surface asunknown variable: showfrom codegen instead of the right typecheck-phase NoInstance Show. -
Bare-prelude-fn calls resolve via implicit-prelude-import at typecheck AND at codegen. A user-module source-level call
(app float_eq 1.5 1.5)writes the bare namefloat_eq, not the qualified formprelude.float_eq. The typechecker resolves this via the auto-injected prelude import (see invariant (2) for the one-way auto-injection rule); codegen'slower_app::resolve_callee+is_static_callee+resolve_top_level_fneach carry a parallel prelude-fallback: when the bare name does not resolve in the caller-module's symbol table, fall back toprelude.<name>. This is the source-level counterpart to invariant (2)'s post-mono fallback — both make the implicit- prelude-import a workspace invariant rather than a typechecker- only feature. The fallback is load-bearing for the surface(app float_eq …)/(app float_lt …)/ etc. of the operator-routing-eq-ord milestone; without it the LLM-author must writeprelude.float_eqexplicitly, which is exactly the syntactic noise the implicit import exists to eliminate.
The four invariants are lockstep partners: invariant (1) creates the
cross-module reference, invariant (2) makes codegen able to resolve
post-mono references, invariant (3) makes the typecheck-time
discharge fire correctly when no instance exists, invariant (4)
makes bare prelude-fn names resolve from user modules at the source
level. Loosening any one breaks the user-ADT trajectory or the
Float-named-fn surface, so each is pinned:
crates/ail/tests/codegen_import_map_fallback_pin.rs (invariant 2),
crates/ail/tests/polyfn_dot_qualified_branch_pin.rs (invariant 3
- lockstep),
crates/ail/tests/float_compare_smoke_e2e.rs(invariant 4 — drives(app float_eq …)end-to-end across the implicit-prelude-import boundary), and the existingcrates/ail/tests/show_user_adtfixture (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.
Cross-class method sharing is 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.
Class-schema diagnostics (validation of class declarations):
InvalidSuperclassParam— superclasstypediffers from the class's ownparam.UnboundConstraintTypeVar— a class method's signature carries a constraint mentioning a type variable bound neither by the method'sforallnor by the classparam.
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.
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. Carries an optionalcandidate_classesfield surfacing the multi-candidate set when the bare-method dispatch path's filter collapses to zero registry survivors.AmbiguousMethodResolution— a monomorphicTerm::Varcall 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— an explicit class qualifier inTerm::Var.namenames a qualified class that is not in the workspace's candidate-class index for the method.class-method-shadowed-by-fn(warning) — aTerm::Varresolved 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).
Ratified by: crates/ail/tests/show_no_instance_e2e.rs.