Files
AILang/docs/specs/2026-05-10-canonical-type-names.md
T

16 KiB
Raw Blame History

Canonical Type Names — Internal Qualification — Design Spec

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

Goal

Establish a single, unambiguous canonical form for Type::Con names inside AILang's internal AST: every non-primitive Type::Con carries its defining-module prefix (prelude.Ordering, not bare Ordering). Bare names are accepted only in the .ail.json surface form as an LLM-author convenience; the loader resolves them once into canonical form. All downstream phases (typecheck, monomorphisation, codegen, content-hash) see the canonicalised AST.

This closes a long-running design ambiguity in AILang: the language has accumulated five separate "imports-fallback" or "qualifier-helper" mechanisms over iters 15a / 23.1.3 / 23.1.4 / 23.2 / 23.2.4, each papering over the bare-vs-qualified divergence at a specific consumer site. The most recent symptom (iter 23.3 Task 4: prelude's compare returning bare Ordering to a user module, mismatching the qualified prelude.Ordering produced by the user-side Pattern::Ctor fallback) is a direct consequence: as long as some sites produce qualified names and others stay bare, values crossing module boundaries inevitably misalign.

The user's directive on 2026-05-10 (German, paraphrased): "Bare names make sense as surface convention because the qualifier can always be omitted. But if qualifiers are MISSING internally, you have a problem." This spec formalises that principle.

Architecture

A single canonicalisation pass runs in load_workspace, between prelude injection (iter 23.1) and registry build:

.ail.json (surface — bare-tolerant)
        |
        v
load_workspace
        |
        +-- parse + import DFS  (existing)
        +-- prelude inject       (existing, iter 23.1)
        +-- canonicalise         <-- NEW (this milestone)
        +-- validate_classdefs   (existing, now sees canonical types)
        +-- build_registry       (existing, now sees canonical types)
        |
        v
check_workspace        (sees canonical AST — no fallbacks needed)
        v
monomorphise_workspace (sees canonical AST)
        v
lower_workspace        (sees canonical AST)

The pass operates per module against the (already-loaded) workspace map. Each Type::Con { name, args } is rewritten using these rules, applied in order:

  1. Already qualified (name.contains('.')): unchanged.
  2. Primitive (Int, Bool, Str, Unit, Float — via ailang_core::primitives::primitive_surface_name): unchanged.
  3. Local hit (current module's Def::Type names contain name): rewrite to {current_module}.{name}.
  4. Imports-fallback hit (exactly one of the module's imports — including the implicit prelude import for non-prelude modules per iter 23.1 — defines a Def::Type called name): rewrite to {owning_module}.{name}.
  5. Zero / 2+ matches: leave name bare. Downstream typechecker fires UnknownType (zero) or AmbiguousType (2+) using the existing diagnostic paths. No new diagnostic variants.

Type::Var is untouched — the AST's tagged-enum representation distinguishes Type::Con from Type::Var via the k field, so the canonicaliser's match arm sees only the right variant. A bare identifier that names both a local type def and an in-scope Type::Var (e.g. forall a. (Foo) -> a where the module also defines data Foo = ...) is not ambiguous: the AST records quantification explicitly, so a Type::Con { name: "Foo" } is never a quantified variable. No additional disambiguation needed.

After the pass, the invariant holds: every Type::Con in the workspace AST is either a primitive name (bare) or a fully qualified module.Type name. Mixed bare-local-with-context never occurs.

Components

The milestone decomposes into five iterations. Each iter's scope is deliberately narrow so per-iter commits remain reviewable.

Iter Scope
ct.1 Loader canonicalise pass. New file crates/ailang-core/src/canonicalise.rs. Walks every Def's declared types (fn signatures, const types, class method types, instance types, type-def ctor field types) plus every Term's nested types (Term::Lam's paramTypes / retType, Term::Ctor's type_name, any annotated Term::Let). Wires into load_workspace between prelude inject and validate_classdefs. Unit tests cover the five resolution rules.
ct.2 Workspace-flat env.types. The per-module overlay in check_in_workspace (~crates/ailang-check/src/lib.rs:1247-1287 — clears workspace-flat env.types, rebuilds per-module-local) is replaced by a single workspace-flat env.types keyed on the qualified canonical name. All env.types.get(name) call sites in synth adjust to look up by qualified name (which is what they already receive post-canonicalisation). The env.module_types workspace-flat-per-module map stays — its consumers are the obsolete fallbacks being deleted in ct.3/ct.4.
ct.3 Remove obsolete typechecker mechanisms: Pattern::Ctor's imports-fallback (iter 15a, ~lib.rs:2486-2521), Term::Ctor's synth imports-fallback (iter 23.1.3, ~lib.rs:1979-2025), and qualify_local_types (iter 15a, ~lib.rs:1848). Each removal is RED-tested against an existing fixture that exercised the fallback path (the fixture still passes — its scrutinee/result types are now pre-canonicalised).
ct.4 Remove obsolete codegen + mono mechanisms: lookup_ctor_by_type's imports-fallback (iter 23.1.4, crates/ailang-codegen/src/lib.rs) and apply_per_module_ctor_index_overlay (iter 23.2 fix 84dcc46, crates/ailang-check/src/mono.rs). The codegen lower_workspace_inner implicit-prelude import_map entry (iter 23.2.4 fix be882c4) STAYS — that's about fn-name resolution for mono symbols, not Type::Con names.
ct.5 DESIGN.md amendments + re-baseline. Decision 2 amended to name the canonical form explicitly. Sections cross-referenced in lines 1067 / 1121 / 1137 / 1302 / 1335 / 1709 / 1719 / 2229 of DESIGN.md re-read and adjusted: their "hashes stay bit-identical" promises are recontextualised as promises within the canonical-form version. Hash-pinned regression tests (iter19b_empty_suppress_preserves_pre_19b_hashes, iter19b_schema_extension_preserves_pre_19b_hashes, and any other named pin) re-baselined to new hashes. IR snapshot fixtures under crates/ail/tests/snapshots/ refreshed. JOURNAL entry documents the canonical-form transition.

After ct.5, iter 23.3 Task 4+5 resume (recreate examples/compare_primitives_smoke.ail.json + the e2e test from the iter-23.3 plan; expected to pass without further compiler changes).

Data flow

Example 1: user-module Term::Ctor against a prelude-defined ADT

Surface form (.ail.json in user module foo):

{ "t": "ctor", "type": "Ordering", "ctor": "LT", "args": [] }

Pre-milestone (today):

  1. AST: Term::Ctor { type_name: "Ordering", ctor: "LT" }.
  2. Term::Ctor synth (iter 23.1.3 fallback) scans env.imports.values() × env.module_types, finds Ordering in prelude, sets result_type_name = "prelude.Ordering".
  3. Inferred expression type: Type::Con { name: "prelude.Ordering" }.

Post-milestone:

  1. Loader canonicalise pass rewrites the Term::Ctor: type_name: "Ordering""prelude.Ordering".
  2. AST after load: Term::Ctor { type_name: "prelude.Ordering", ctor: "LT" }.
  3. Term::Ctor synth uses local ctor-resolution against the qualified name; no fallback needed.
  4. Inferred expression type: Type::Con { name: "prelude.Ordering" }.

Result identical, mechanism removed.

Example 2: cross-module value flowing into a match (the iter 23.3 bug)

Prelude declares class Ord a where compare : (a borrow, a borrow) -> Ordering. User module calls compare 1 2 and matches on the result.

Pre-milestone:

  1. Prelude's compare method signature AST: (a, a) -> Type::Con { name: "Ordering" } — bare.
  2. User-side typechecker resolves compare, propagates bare Ordering to the match scrutinee.
  3. Pattern::Ctor's iter 15a fallback resolves LT to prelude.Ordering.
  4. Equality check at lib.rs:2526: "Ordering" == "prelude.Ordering" → false → pattern-type-mismatch.

Post-milestone:

  1. Loader canonicalise pass rewrites prelude's compare method type: ret "Ordering""prelude.Ordering" (the local-hit rule applies — Ordering IS local to prelude, so prelude's own module name is the prefix).
  2. User-side typechecker propagates prelude.Ordering to the match scrutinee.
  3. Pattern::Ctor uses local ctor-name resolution (the user's env.ctor_index doesn't have LT, but env.types.get("prelude.Ordering") returns the TypeDef directly; check ctor name match against td.ctors). No fallback needed.
  4. Equality check trivially succeeds. Bug closed by construction.

The bug was structural: as long as prelude's own method signature carried bare Ordering while user-side resolution produced qualified prelude.Ordering, no per-site fix could close the gap. Canonicalising the loader-time AST is the only place the invariant unifies.

Error handling

No new diagnostics. The canonicaliser delegates errors to the existing typechecker paths:

  • Truly unknown type name (zero matches, neither local nor imported): canonicaliser leaves the name bare. Downstream synth / check_type_well_formed fires CheckError::UnknownType exactly as today.
  • Ambiguous type name (2+ imports define a type with the same bare name): canonicaliser leaves the name bare. Downstream synth fires CheckError::AmbiguousType (added in iter 23.1.3) via its imports-walking path. After ct.3 removes the imports-fallback, an ambiguous bare name reaching downstream is itself a UnknownType — but the canonicalise pass can detect ambiguity locally and emit a workspace-load-time WorkspaceLoadError::AmbiguousTypeName to keep the diagnostic at the right phase. This is a design choice within the canonicaliser; not load-bearing on the spec.

The canonicaliser is otherwise total — it never fails. Unrecognised names just pass through bare.

Testing strategy

  • Unit tests in crates/ailang-core/src/canonicalise.rs::mod tests:

    • Bare local → qualified-local (e.g. fixture data Foo in module m, declared fn (Foo) -> Int rewrites to (m.Foo) -> Int).
    • Bare imported-single → qualified-import.
    • Bare imported-ambiguous (2+) → left bare (or workspace-load error, see Error handling).
    • Bare primitive (Int, Bool, Str, Unit, Float) → bare.
    • Already-qualified mod.Type → unchanged.
    • Type::Var (e.g. forall a. (a) -> a) → untouched.
  • Workspace integration tests:

    • Iter 23.3 Task 4's E2E fixture (examples/compare_primitives_smoke.ail.json, recreated after ct.5) compiles, runs, and prints 1\n2\n3\n1\n2\n3\n1\n2\n3\n.
    • Every existing E2E test still passes (with re-baselined hashes / snapshots).
    • The five obsolete-mechanism removals (ct.3 + ct.4) each pinned by an existing fixture that exercised the fallback (e.g. iter-23.1.4's bare-ctor E2E ordering_match_via_prelude_prints_1).
  • Hash regression tests: re-baselined once in ct.5. They continue to pin form stability ("schema additions don't change hashes") but at the new canonical-form's hashes.

  • Bench scripts: run at milestone close to confirm no IR-shape regression caused by the loader pass. Hypothesis: canonicalisation adds a constant per-workspace-load overhead, no per-call regression. If regression fires, dispatch ailang-bencher.

Acceptance criteria

  1. After load_workspace completes, the invariant holds workspace-wide: every Type::Con in every Def's signatures and every nested Type in every Term is either a primitive name (bare) or a fully qualified module.Type name. No mixed forms.

  2. The five obsolete mechanisms in Components are deleted entirely (NOT retained as defensive guards):

    • Pattern::Ctor imports-fallback (iter 15a, lib.rs:2486-2521)
    • Term::Ctor synth imports-fallback (iter 23.1.3, lib.rs:1979-2025)
    • qualify_local_types helper (iter 15a, lib.rs:1848)
    • lookup_ctor_by_type codegen imports-fallback (iter 23.1.4)
    • apply_per_module_ctor_index_overlay in mono (iter 23.2 fix 84dcc46)

    Code search for imports-fallback or imports.values().*module_types in the typechecker + codegen returns ZERO matches related to Type::Con resolution.

  3. The codegen implicit-prelude import_map fix (lower_workspace_inner, iter 23.2.4 be882c4) STAYS — different problem (fn-name resolution for mono symbols, not Type::Con).

  4. cargo build --workspace && cargo test --workspace is FULL green at milestone close.

  5. Iter 23.3 Task 4+5 resume after this milestone closes and complete without any further canonical-form-related compiler changes.

  6. DESIGN.md amended:

    • Decision 2 (content-addressed defs) gains an explicit canonical-form rule for type names.
    • The "hashes stay bit-identical" promises at lines 1067, 1121, 1137, 1302, 1335, 1709, 1719, 2229 are reviewed and adjusted where they implicitly relied on the old canonical form.
    • The JOURNAL entry documents the migration as a versioned canonical-form transition (one-time hash shift, deliberate, ratified by this spec's acceptance).
  7. The three bench scripts (bench/check.py, bench/compile_check.py, bench/cross_lang.py) exit 0, or any non-zero is ratified per the audit-skill rules with a JOURNAL entry naming the iter that moved the metric.

Out of scope

  • Term-level names (function names, var names, ctor names in patterns): unchanged. Their resolution mechanisms (Term::Var with prefix.name form, Pattern::Ctor's local ctor_index, etc.) work correctly — bare ctor names like LT are looked up against the now-qualified scrutinee's type, which provides unambiguous resolution.

  • Surface notation: bare names in .ail.json continue to be the LLM-author convention. The loader resolves them. The Form-A prose printer continues to display bare local names (Ordering not prelude.Ordering) for readability — that's pretty-printing, not canonical form.

  • Schema version bump: the canonical form is internal; the .ail.json schema (ailang/v0) is unchanged. Existing fixtures continue to parse without rewriting.

  • Effects, modes, type-def schema, ctor field shape: unchanged.

Migration impact (one-time)

This is a versioned canonical-form transition. The previous canonical form (bare-local-with-context) was structurally incomplete; the new form (qualified-everywhere-internal) is complete. The migration:

  • Every canonical-JSON hash in the codebase shifts (because canonical::type_hash walks the AST including Type::Con names). One-time, intentional, ratified by this spec. The pre-transition hashes are not preserved — they were artefacts of an incomplete canonical form.
  • Hash-pinned regression tests are re-baselined once at ct.5. They continue to pin form stability under future schema additions, just at new hash values.
  • IR snapshot fixtures under crates/ail/tests/snapshots/ shift if the IR text depends on type names (it does, via mangling). Re-baselined at ct.5.
  • No external clients are affected — AILang is pre-1.0 and not yet consumed by anything outside the repo.

Note on canonical-hash stability promises in DESIGN.md

The repeated promises across DESIGN.md (hashes stay bit-identical when feature X is added) are not absolute promises about the bit-sequence of hashes for all time. They are conditional promises within a given canonical-form version: when feature X is added, the canonical-form rule is unchanged, so existing modules emit the same bytes and hash to the same values. This spec changes the canonical-form rule itself, so the promises are correctly re-interpreted as: "within the new canonical form, future feature additions continue to preserve hashes". Ct.5's DESIGN.md amendment makes this explicit.