spec: canonical type names — internal qualification
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# 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`):
|
||||
```json
|
||||
{ "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.
|
||||
Reference in New Issue
Block a user