Files
AILang/docs/specs/0023-module-qualified-class-names.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

33 KiB
Raw Blame History

Module-qualified class names + type-driven method dispatch — Design Spec

Date: 2026-05-13 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude

Goal

Retire the workspace-global WorkspaceLoadError::MethodNameCollision pre-pass (crates/ailang-core/src/workspace.rs:547) that today enforces a single class per method name across the entire workspace. After retirement, two libraries (e.g. the prelude and a user module) can each declare class Show (or class Eq, class Ord, …) with their own show method, and both can ship instances for overlapping types. The retirement unblocks the deferred milestone 24 (Show + print rewire) by allowing the prelude to ship class Show independently of the 14 examples/test_22b{1,2,3}_*.ail.json fixtures that today declare a user-class named Show.

The replacement mechanism is type-driven, fail-closed dispatch:

  • Polymorphic call sites: Constraint.class (now fully qualified per the canonical-form extension below) names the class; synth's residual is keyed by (qualified-class, method). No ambiguity by construction.
  • Monomorphic call sites: synth emits a residual carrying the candidate-class set; constraint-discharge refines by (class, type_hash(arg-type)) ∈ Registry; unique survivor wins; multiple → CheckError::AmbiguousMethodResolution; LLM-author writes an explicit qualifier (<module>.<Class>.<method> <args>) to disambiguate.

Three schema fields move from bare to canonical (symmetric to ct.1's Type::Con.name rule): InstanceDef.class, Constraint.class, SuperclassRef.class. ClassDef.name stays bare (defining-site context, symmetric to TypeDef.name).

Why this is the right shape: the canonical-type-names milestone explicitly out-of-scoped class-reference fields and named MethodNameCollision as the load-bearing workaround that made bare class names viable (docs/specs/0007-canonical-type-names.md §"Out of scope: Class names"). This milestone closes that loop. The workaround was honest about being a workaround; this is the milestone that retires it.

Architecture

Model. Class names are canonicalised throughout the schema by the same rule ct.1 applies to Type::Con.name: bare for same-module references, qualified (<module>.<Class>) for cross-module references. Method dispatch becomes type-driven: synth's Term::Var arm consults a method → candidate-class index, refines by argument type and active constraints, fails closed on genuine ambiguity.

Three schema fields are canonicalised (Iter 1):

  • InstanceDef.class (crates/ailang-core/src/ast.rs:314) — bare → canonical.
  • Constraint.class (crates/ailang-core/src/ast.rs:345) — bare → canonical.
  • SuperclassRef.class (crates/ailang-core/src/ast.rs:279) — bare → canonical.
  • ClassDef.name (crates/ailang-core/src/ast.rs:262) stays bare (defining site, symmetric to TypeDef.name).

ct.1's validate_canonical_type_names validator (crates/ailang-core/src/workspace.rs:921) is extended with three new field walks symmetric to the existing Type::Con.name walk. Existing BareCrossModuleTypeRef diagnostic (workspace.rs:341) extended OR sibling diagnostic BareCrossModuleClassRef added (implementer chooses; both shapes carry the same information — {module, name, candidates}). Validator runs BEFORE class-schema validation (already does at workspace.rs:486), so a bare cross-module class-ref fires the canonical-form diagnostic before MissingMethod or OrphanInstance.

Registry key is qualified (Iter 1). build_registry (workspace.rs:521):

  • Pass-1: class_def_module: BTreeMap<String, String> (the bare-class-name → defining-module map) becomes class_def_module: BTreeMap<QualifiedClassName, String> keyed by the qualified class name (<defining_module>.<class_name>).
  • Pass-2: entries: BTreeMap<(QualifiedClassName, type_hash), RegistryEntry> ditto.
  • RegistryEntry.class field carries qualified strings.

ClassMethodEntry.class_name (crates/ailang-check/src/lib.rs) carries qualified strings. The format!("{class} {type}")-shaped diagnostic messages already include both module and class context in their Origin::Class { class_name, module } form, so the user-facing shape stays informative.

Sekundärindex method_to_candidate_classes (Iter 2). New map BTreeMap<MethodName, BTreeSet<QualifiedClassName>> built in the same pass as class_methods. Inverse map: for each method, the set of classes that declare it. In Iter 2 this set has cardinality ≤ 1 by MethodNameCollision's invariant; in Iter 3 cardinality > 1 becomes legal.

Synth Var-arm rewrite (Iter 2). The single resolution point at crates/ailang-check/src/lib.rs:1968 is rewritten:

  1. Parse name: take the last dot-segment as method name, the prefix (possibly empty) as a class qualifier. The prefix is itself a possibly-qualified class name (prelude.Show or just Show).
  2. Consult method_to_candidate_classes[method]. If absent → fall through to the existing other Var-arm branches (locals, fns, etc.).
  3. Qualifier present: filter candidates to the class named by the qualifier. Empty result → CheckError::UnknownClass. Single result → continue at (5).
  4. Qualifier empty (bare method form): if |candidates| == 1 → continue at (5). Otherwise emit a multi-candidate residual carrying the candidate set + the freshly-instantiated class-param metavar + the method name. Discharge handles refinement (see below).
  5. Push ResidualConstraint { class: <qualified>, type_: fresh, method } as today. Return the instantiated method type.

Constraint-discharge refinement (Iter 2). The discharge path (today: registry lookup at fn-body-end on each residual) is extended to handle the multi-candidate variant:

  1. If the residual's type_ is concrete (resolved to non-var) → filter candidates on (class, type_hash(type_)) ∈ Registry. Single survivor → discharge against that registry entry. Zero survivors → NoInstance (with a candidate-classes list in the diagnostic, so the LLM-author knows which class lacks an instance). Multiple survivors → AmbiguousMethodResolution.
  2. If the residual's type_ is still a rigid type variable (e.g. inside a polymorphic fn body) → filter candidates against the active declared constraints: keep classes C such that Constraint { class: C, type: t } is in the fn's declared constraint set with t unifying with the residual's type_. Single survivor → propagate as a single-class declared constraint (today's path). Zero survivors → MissingConstraint (with the candidate classes named). Multiple survivors → MissingConstraint that names the candidates and instructs the LLM to add one of the declared constraints.

MethodNameCollision retirement (Iter 3). Variant (workspace.rs:303), pre-pass (workspace.rs:547-627), Display arm (crates/ail/src/main.rs:1201), and the two pin tests (workspace.rs:1931, 1965) are deleted. The deletions are mechanical; the post-delete workspace-load path is:

load_workspace → visit graph → inject prelude →
validate_canonical_type_names → validate_classdefs → build_registry

No method-collision check between them — the collision is no longer a load-time invariant, it's a per-call-site resolution outcome.

Class-fn collisions are a special case of method ambiguity in the new model. Today's MethodNameCollision { kind: "class-fn" } pre-pass forbids class C { eq } + fn eq workspace-wide. Post-retirement, the combination is allowed; at a call site with Term::Var { name: "eq" }, synth tries the existing precedence (locals → caller-module-fn → imported-fn) first; if no fn match, falls through to the method_to_candidate_classes lookup. If BOTH a fn eq and a class candidate for the arg type exist → the existing lookup-precedence rule applies (fn wins) AND a structured warning fires (because the LLM likely didn't realise the shadowing happened — implementer drafts the warning text). Alternatively (implementer choice): hard-error AmbiguousMethodResolution-style. Default in this spec: lookup precedence + warning, because (a) the existing precedence rule is already in place for plain free-fn lookup, (b) shadowing is the LLM-natural surface convention (local binding wins over a class method), and (c) the warning surfaces the rare case where the shadowing is accidental.

Components (iterations)

Iter Scope
1 Canonical-form extension for class-ref fields + workspace internal qualification. Three schema fields move bare → canonical: InstanceDef.class, Constraint.class, SuperclassRef.class. ct.1's validate_canonical_type_names validator gets three new field-walks symmetric to the existing Type::Con.name walk; diagnostic surface uses the existing BareCrossModuleTypeRef or a sibling BareCrossModuleClassRef (implementer chooses). Pass-1 of build_registry keys class_def_module by qualified class name. Pass-2's entries keyed by (QualifiedClassName, type_hash). ClassMethodEntry.class_name carries qualified strings. All cross-module consumers (mono's instance lookup, the coherence-check mod_name == class_mod comparison, the superclass-existence check) read qualified. MethodNameCollision's Origin::Class.class_name migrated to qualified (the existing format!("class {class_name} (in {module})") shape stays unambiguous). examples/prelude.ail.json migration: intra-prelude refs stay bare per the canonical-form rule (the prelude's Eq/Ord references inside instance and Constraint are all same-module → no diff in the prelude). 14 test fixtures under examples/test_22b* migrated where they carry cross-module refs; intra-fixture-module refs stay bare. crates/ail/tests/typeclass_22b{2,3}.rs assertions migrated where they assert on the qualified form. cargo test --workspace green. bench/check.py && bench/compile_check.py && bench/cross_lang.py exit 0 or audit-ratified.
2 Type-driven dispatch mechanism installed (mechanism-before-exercise). New parallel index method_to_candidate_classes: BTreeMap<String, BTreeSet<QualifiedClassName>> built in build_check_env (or sibling) alongside class_methods. Synth Var-arm at crates/ailang-check/src/lib.rs:1968 rewritten per the Architecture's 5-step rule. New residual variant (or ResidualConstraint-enum extension; implementer chooses) carrying { candidates: BTreeSet<QualifiedClassName>, type_: Type, method: String }. Constraint-discharge extended to handle multi-candidate residuals per the Architecture's refinement rules. New diagnostic variants CheckError::AmbiguousMethodResolution { method, type_repr, candidate_classes: Vec<String> } and CheckError::UnknownClass { name: String } with structured-diagnostic codes ambiguous-method-resolution and unknown-class. Existing CheckError::NoInstance extended (additive — optional candidate_classes field) to surface the candidate set on the unique-class-path miss case. Unit tests on the new resolve_method_dispatch function (or equivalent name; implementer chooses): synthetic inputs covering the 6 cases enumerated in Testing strategy. Mono's class-method-residual rewriter (crates/ailang-check/src/mono.rs:1233 and surrounding) consumes the new index where needed; the existing call-site rewrite shape (<method>__<typesurfacename> with cross-module <defining_module>.<...> prefix) is unchanged because qualified class names are already in scope post-iter-1. cargo test --workspace green. End-to-end multi-class fixtures NOT yet possible because MethodNameCollision still gates real workspaces — the new path is exercised exclusively by unit tests in this iter. Bench-regression ratified or held.
3 Retire MethodNameCollision. Variant (workspace.rs:303) + pre-pass (workspace.rs:547-627, including Origin enum) + Display arm (crates/ail/src/main.rs:1201) + the two pin tests at workspace.rs:1931 and workspace.rs:1965 deleted. The two pin tests are REPURPOSED to assert the opposite invariant: same fixtures load without error AND method_to_candidate_classes carries the expected multi-entry set. Three new positive E2E fixtures: (a) two-module workspace with two Show classes (each with Show Int); bare show 42 at a third call-site module fires AmbiguousMethodResolution naming both candidate classes. (b) same workspace; explicit <modA>.Show.show 42 resolves to A's class without diagnostic. (c) two-module workspace with class Eq in modA + fn eq in modB; bare eq x y at a call-site module that imports both resolves to modB's fn per lookup precedence AND emits the new fn-shadows-class-method warning (or fails closed per implementer choice — see Architecture above). DESIGN.md amendment: §"Class-schema diagnostics" loses the MethodNameCollision paragraph; gains a paragraph on AmbiguousMethodResolution + class-fn lookup-precedence. New DESIGN.md subsection "Method dispatch" anchors the two-mode dispatch (poly via constraint, mono type-driven) and points at method_to_candidate_classes as the load-bearing data structure. WhatsNew.md entry on milestone close. Roadmap update: P2 entry → [x], milestone 24 depends on: line removed, milestone 24 entry annotated "ready for re-brainstorm". cargo test --workspace green. Bench-regression ratified.

The milestone closes with the standard audit pipeline (architect drift review + three bench scripts).

Data flow

Five trajectories show the dispatch at work post-Iter-3.

Trajectory A — bare call, unique candidate (most common case)

eq 1 2 in a workspace where only prelude.Eq declares method eq.

  1. Synth Var-arm parses name = "eq" → method "eq", qualifier-prefix empty.
  2. method_to_candidate_classes["eq"] = {prelude.Eq}. Singleton.
  3. Push ResidualConstraint { class: "prelude.Eq", type_: fresh, method: "eq" }.
  4. App-arm unification binds fresh to Int via the arg's type.
  5. Discharge: (prelude.Eq, type_hash(Int)) ∈ Registry → resolved. Mono synthesises eq__Int.

Trajectory B — bare call, multi-candidate, type-driven refinement to unique

show 42 in a workspace where both prelude.Show and userlib.Show declare method show, but only prelude.Show has Show Int.

  1. Synth Var-arm: method "show", qualifier-prefix empty.
  2. method_to_candidate_classes["show"] = {prelude.Show, userlib.Show}.
  3. Push a multi-candidate residual carrying {candidates, type_: fresh, method}.
  4. App-arm unification binds fresh to Int.
  5. Discharge: filter candidates on (class, type_hash(Int)) ∈ Registry → only prelude.Show survives. Discharge proceeds. Mono synthesises show__Int from prelude.Show's instance body.

Trajectory C — bare call, multi-candidate, true ambiguity → diagnostic

Same workspace as B, but both prelude.Show and userlib.Show also ship Show Int. show 42 at a monomorphic call site:

14 as in B. 5. Discharge: filter survives both prelude.Show and userlib.ShowCheckError::AmbiguousMethodResolution { method: "show", type_repr: "Int", candidate_classes: ["prelude.Show", "userlib.Show"] }. Diagnostic message names both classes and instructs the LLM-author to rewrite as prelude.Show.show 42 or userlib.Show.show 42.

Trajectory D — bare call inside polymorphic fn body, constraint-driven refinement

A free fn print : forall a. prelude.Show a => (a) -> () !IO (the qualified-Constraint.class form post-Iter-1) with body containing show x:

  1. Synth Var-arm at the inner show: method "show", qualifier-prefix empty. method_to_candidate_classes["show"] = {prelude.Show, userlib.Show}.
  2. Push a multi-candidate residual {candidates, type_: fresh, method}.
  3. App-arm unification: arg x : a → fresh unifies with the rigid var a.
  4. Discharge: type_ is the rigid a, can't drive registry lookup. Fall back to constraint-driven filter. The fn's declared constraint set is [{ class: "prelude.Show", type: a }]. The residual's type_ unifies with that constraint's type (both are a). Filter candidates on class-name-matches-a-declared-constraint → {prelude.Show}. Unique. Discharge proceeds: the multi-candidate residual collapses into a single-class declared constraint propagation (the existing path).
  5. Mono synthesises print__Int (or whatever the concrete arg type ends up); inside that body, show__Int is scheduled in the same fixpoint round per the 23.4 unification.

Trajectory E — explicit-qualifier call

prelude.Show.show 42 at a monomorphic call site (the LLM-author's remedy after a Trajectory-C diagnostic):

  1. Synth Var-arm parses name = "prelude.Show.show" → method "show", qualifier-prefix "prelude.Show".
  2. method_to_candidate_classes["show"] = {prelude.Show, userlib.Show}. Filter on qualifier-equals-class → {prelude.Show}. Singleton.
  3. Push ResidualConstraint { class: "prelude.Show", type_: fresh, method: "show" }. 45 as in Trajectory A.

If the qualifier names a class not in the workspace (unknownlib.Show.show 42) → CheckError::UnknownClass { name: "unknownlib.Show" }.

Error handling

  • BareCrossModuleClassRef (or BareCrossModuleTypeRef reuse) — ct.1-validator diagnostic for the three class-ref fields when a bare form is used to reference a cross-module class. Same shape as the existing BareCrossModuleTypeRef (workspace.rs:341): carries module, name, candidates. Implementer chooses whether to reuse the existing variant (extending the name field's semantics to cover class refs) or add a sibling. Default: sibling, because the user-facing wording ("type" vs "class") differs.

  • AmbiguousMethodResolution { method, type_repr, candidate_classes } — new check-time diagnostic. Fires at constraint-discharge time when the candidate-class set has more than one survivor after type-driven filtering at a monomorphic call site. Message names the candidate classes and instructs the LLM to write the explicit qualifier form. Structured-diagnostic code: ambiguous-method-resolution.

  • UnknownClass { name } — new check-time diagnostic. Fires when an explicit class qualifier in Term::Var.name names a qualified class that's not in the workspace. Structured-diagnostic code: unknown-class.

  • NoInstance — existing diagnostic shape stays; gains an optional candidate_classes field surfacing the candidate set on the unique-class-path miss case (additive change). Existing display wording stays for the singleton case; with candidate_classes it surfaces the broader context.

  • MissingConstraint — existing diagnostic; in the new model carries the candidate class set when the bare method is ambiguous at a polymorphic site lacking a declared constraint (Trajectory D failure mode).

  • MethodNameCollision — variant DELETED in Iter 3.

  • Class-fn collision warning (Iter 3) — new structured warning, fires when a Term::Var { name } resolves via fn lookup precedence AND a class method of the same name exists with an instance for the arg type. Structured-diagnostic code: class-method-shadowed-by-fn. Implementer drafts the wording.

Testing strategy

Iter 1 — canonical-form extension

  • Validator pin tests: symmetric to the existing class_param_in_applied_position_fires_canonical_form_rejection. Three new tests:

    • Bare cross-module InstanceDef.class fires BareCrossModuleClassRef (or reused variant); message names candidate qualified forms from the owning module's imports.
    • Bare cross-module Constraint.class fires the same.
    • Bare cross-module SuperclassRef.class fires the same.
  • Round-trip: examples/prelude.ail.json parses + canonicalises bit-stable post-migration (zero diff for the prelude; all internal class-refs stay bare).

  • Cross-module fixture: a workspace with instance prelude.Eq (the qualified form) loads and checks cleanly; mirrors the existing cross-module fixture pattern from milestone 23.

  • Migration coverage: the 14 examples/test_22b* fixtures parse + canonicalise post-migration; cargo test --workspace green unchanged.

Iter 2 — type-driven dispatch mechanism

  • Unit tests on resolve_method_dispatch (or whatever the implementer names the new function). Six cases, each as an isolated unit test calling into the dispatch logic with synthetic inputs:

    • Unique candidate: returns the unique class.
    • Multi-candidate + explicit qualifier matching one: returns that class.
    • Multi-candidate + explicit qualifier matching none: UnknownClass.
    • Multi-candidate + type-driven filter narrows to one: returns that class.
    • Multi-candidate + constraint-driven filter narrows to one (rigid var case): returns that class.
    • Multi-candidate + neither filter narrows: AmbiguousMethodResolution.
  • Structured-diagnostic pin tests for the new diagnostic JSON shapes (ambiguous-method-resolution, unknown-class).

  • Existing end-to-end tests unchanged. MethodNameCollision still gates real workspaces.

  • Bench regression held or audit-ratified.

Iter 3 — MethodNameCollision retirement + multi-class E2E

  • Repurposed pin tests: the two workspace.rs:1931, 1965 tests re-asserting no-error on the same fixtures plus method_to_candidate_classes carries 2 entries.

  • Three new positive E2E fixtures (under examples/, naming TBD by implementer):

    • Two-module workspace with two Show classes; bare show 42 fires AmbiguousMethodResolution.
    • Same workspace; explicit <modA>.Show.show 42 resolves cleanly, stdout matches.
    • Two-module workspace with class Eq + fn eq; bare eq x y resolves per precedence + warning (or fails closed; see Architecture).
  • DESIGN.md content audit: the existing class-schema diagnostics section + the dispatch story are present and accurate.

  • Bench regression at milestone close: three scripts exit 0 or audit-ratified per audit-skill convention.

Acceptance criteria

  1. Iter 1 landed: three class-ref fields canonicalised across schema, validator, and consumers; ct.1 validator extended; prelude

    • 14 test fixtures migrated; existing tests green.
  2. Iter 2 landed: method_to_candidate_classes index installed; synth Var-arm rewritten per the 5-step rule; multi-candidate residual + discharge refinement implemented; AmbiguousMethodResolution

    • UnknownClass diagnostic variants shipped; unit tests cover the 6 cases; existing E2E tests unchanged.
  3. Iter 3 landed: MethodNameCollision variant + pre-pass + Display arm struck; pin tests repurposed; three new positive E2E fixtures shipping; DESIGN.md amended with the new dispatch subsection.

  4. cargo test --workspace green across all three iters.

  5. Bench regression: bench/check.py && bench/compile_check.py && bench/cross_lang.py exit 0 or audit-ratified with journal entry.

  6. Roadmap update: P2 entry "Module-qualified class names + type-driven method dispatch" → [x]; milestone 24 depends on: line removed; milestone 24 entry marked "ready for re-brainstorm"; WhatsNew.md entry shipped on Iter-3 close.

Out of scope (deferred, with substantive rationale)

  • Milestone 24 (Show + print rewire). Re-brainstorms separately against the post-retirement architecture per the deferral note at docs/specs/0022-24-show-print.md's Status header. The re-brainstorm is queued automatically once Iter 3 closes.

  • Overlapping instances within a single class. Already handled by DuplicateInstance (workspace.rs:680). Not affected by this milestone; two instances Show Int from the same class still error out.

  • Open type families / functional dependencies. Substantial feature; not part of this milestone's scope.

  • Higher-kinded class params. Decision 11 axis 5 explicitly excludes higher-kinded class params; this milestone does not change that.

  • Renaming or removing ModuleGlobals::class_methods. Stays; the new method_to_candidate_classes index lives alongside. class_methods continues to serve callers that want quick (method-name → first-class-entry) lookup. Implementer audits in Iter 3 whether the channel becomes dead code post-retirement; if so, retirement is a separate P3 tidy.

  • Open commitment on the surface-form question. The qualified-call form prelude.Show.show 42 is the canonical (JSON) shape via dotted Term::Var.name. The .ail Surface form's lexer/parser already handles dotted identifiers; no surface parser change needed. If a future surface ergonomics question (e.g. dedicated #-style or ::-style class-method syntax) surfaces, it's a separate spec.

  • Mass migration of existing fixtures to use cross-module class refs. Iter 1 migrates only what needs to migrate (cross-module refs); same-module refs stay bare per the canonical-form rule. No proactive rewrite to "demonstrate" qualified refs.

Open commitments (implementer choices)

  • Multi-candidate residual representation. Spec prescribes "the residual carries the candidate set and the type_ metavar; discharge filters by (class, type_hash) ∈ Registry or by active declared constraints". Implementer chooses: extend ResidualConstraint with an optional candidates: Option<BTreeSet<...>> field, or add a sibling residual type. Constraint: the existing single-class discharge path stays usable.

  • Sibling vs extended diagnostic variant for class-ref canonical-form rejection. Default in this spec: sibling BareCrossModuleClassRef (because user-facing wording differs between "type" and "class"). Implementer may collapse into the existing BareCrossModuleTypeRef with a kind field if the saving is real and the diagnostic text remains clear.

  • Class-fn collision behaviour. Default: lookup precedence (fn wins over class method) + structured warning class-method-shadowed-by-fn. Alternative: hard-error AmbiguousMethodResolution-style. The default avoids breaking the existing fn-lookup-precedence rule for non-class-method cases (prelude.ne shadowing in user code, etc.); the warning surfaces the rare accidental case. If the implementer's prototyping uncovers a concrete case where the warning is annoying or the hard-error is required for correctness, escalate via the per-iter journal.

  • Diagnostic wording. Implementer drafts the user-facing message text for AmbiguousMethodResolution, UnknownClass, the new ct.1 class-ref diagnostic, and the class-method-shadowed-by-fn warning. Orchestrator reviews in spec-compliance phase of each iter.

  • ModuleGlobals::class_methods retirement. Iter 3 audits whether any consumer remains after the new index lands. If none, a P3 tidy item gets queued; if real consumers exist (e.g. mono's residual rewriter at mono.rs:1233), the channel stays and the new index lives alongside.

Known costs

  • Iter 1: schema migration touchpoints — examples/prelude.ail.json ~0 lines (intra-prelude refs stay bare); 14 test fixtures (only cross-module refs, estimate ~1020 lines total); two test Rust files (assertions migrated where they reference cross-module class strings, estimate ~10 lines). ct.1 validator: ~50 LOC for three new field walks + 01 new diagnostic variant + Display arm. Registry-key migration: ~10 sites across workspace.rs + mono.rs + check/lib.rs reading the class key, all mechanical (qualified-string convention, no type change).

  • Iter 2: new method_to_candidate_classes index + builder ~30 LOC. Synth Var-arm rewrite ~40 LOC (replaces existing ~30 LOC at lib.rs:1968-1996). New residual variant + discharge logic ~6080 LOC. Two new CheckError variants ~30 LOC. Unit tests for the new dispatch function ~150200 LOC.

  • Iter 3: variant + pre-pass + Display arm + helper deletion: net 150 LOC. Three new E2E fixtures + assertions ~200 LOC. DESIGN.md amendment ~30 lines. WhatsNew entry ~5 lines.

  • Net code: approximately neutral across the milestone. Iter 1 is mostly migration (small net positive). Iter 2 adds the mechanism. Iter 3 deletes the workaround. The point of the milestone is not size reduction; it's lifting an unjustified workspace-global invariant.

  • Compile-time shift: the new dispatch path consults method_to_candidate_classes per Term::Var matching a class method. The lookup is BTreeMap::get plus filtering of a small set; net cost per class-method call site is O(|candidates|), bounded by the number of classes that declare a same-named method (workspace- wide, currently zero by MethodNameCollision; post-retirement, small). Expected within bench tolerance.

Load-bearing assumptions about current behaviour

Surfaced for Step 7.5 grounding-check.

  1. ct.1's validate_canonical_type_names validator (crates/ailang-core/src/workspace.rs:921) is the active gate for the canonical-form rule on Type::Con.name and Term::Ctor.type_name. Ratified by the canonical-type-names milestone close (audit-ct-tidy, journal docs/journals/2026-05-12-audit-ct-tidy.md).

  2. WorkspaceLoadError::BareCrossModuleTypeRef (workspace.rs:341) is the existing diagnostic for bare cross-module type refs, ratified by the test class_param_in_applied_position_fires_canonical_form_rejection plus the BareCrossModuleTypeRef pin tests at workspace.rs:2114, 2136.

  3. ClassDef (crates/ailang-core/src/ast.rs:260) has fields name: String (the defining class name, bare per the convention), superclass: Option<SuperclassRef> (optional single superclass), and methods: Vec<ClassMethod>.

  4. SuperclassRef.class is a String at crates/ailang-core/src/ast.rs:279; today carries bare names; the migration target.

  5. InstanceDef.class is a String at crates/ailang-core/src/ast.rs:314; today carries bare names; the migration target.

  6. Constraint.class is a String at crates/ailang-core/src/ast.rs:345; today carries bare names; the migration target.

  7. WorkspaceLoadError::MethodNameCollision (workspace.rs:303) lives with a kind: &'static str discriminator; ratified by the two pin tests at workspace.rs:1931 and workspace.rs:1965.

  8. The MethodNameCollision pre-pass at workspace.rs:547627 is the single workspace-load-time enforcement point of the method-name-uniqueness invariant; nothing else enforces this invariant.

  9. ClassMethodEntry (in crates/ailang-check/src/lib.rs) carries fields class_name: String, class_param: String, method_ty: Type, defining_module: String. Today class_name is bare.

  10. ModuleGlobals::class_methods: IndexMap<String, ClassMethodEntry> (crates/ailang-check/src/lib.rs:1075) is keyed by method name and consumed in synth's Var-arm at crates/ailang-check/src/lib.rs:1968.

  11. build_registry (workspace.rs:521) builds entries: BTreeMap<(String, String), RegistryEntry> keyed by (bare_class_name, type_hash); consumed by mono's instance lookup and the workspace-load-time coherence check (workspace.rs:680 for the DuplicateInstance check).

  12. Synth's Var-arm at crates/ailang-check/src/lib.rs:1968 is the single resolution point for Term::Var { name } where the name resolves to a class method; rewriting this arm covers every class-method call site.

  13. ResidualConstraint (crates/ailang-check/src/lib.rs:1692) discharge happens at function-body check time for monomorphic discharge and via Type::Forall.constraints propagation for polymorphic discharge. Discharge sites: lib.rs:1627 (MissingConstraint) and lib.rs:1651 (NoInstance).

  14. Mono's class-method residual rewriter (crates/ailang-check/src/mono.rs:1233 and surrounding) uses the class_methods map to identify class-method call sites; this consumer is in scope for Iter 2.

  15. The 14 test fixtures named in the deferral note (examples/test_22b{1,2,3}_*.ail.json) declare user-classes named Show. The prelude (examples/prelude.ail.json) has no Show class today (only Eq/Ord per milestone 23).

  16. The canonical-form rule at ct.1 fires the validator BEFORE class-schema validation (validate_classdefs at workspace.rs:491), so the new class-ref canonical-form check fires before downstream class-schema diagnostics.

  17. examples/prelude.ail.json intra-module class refs (Eq referenced from Ord, Eq/Ord referenced from instances and constraints inside the prelude) stay bare under the canonical-form rule; post-Iter-1 the prelude file is unchanged for class-ref fields.

  18. class_def_module (built in build_registry pass-1 at workspace.rs:526) is the BTreeMap<String, String> mapping bare class names to defining modules; this map's key type migrates to qualified class names in Iter 1.