iter mq.2: type-driven dispatch mechanism installed (mechanism-before-exercise)

Installs the dispatch infrastructure for type-driven method
resolution without retiring MethodNameCollision yet. The new
multi-candidate path is exercised exclusively by 15 unit tests;
real workspaces continue producing single-class residuals
(candidates: None) and every pre-mq.2 fixture typechecks unchanged.

Three new CheckError variants:
- AmbiguousMethodResolution (multi-candidate after type-driven filter)
- UnknownClass (explicit qualifier names a class not in registry)
- NoInstance gains additive candidate_classes field (Vec<String>)

Schema/Env additions:
- Env.method_to_candidate_classes: BTreeMap<String, BTreeSet<String>>
  workspace-flat inverse of class_methods, built in build_check_env.
- ResidualConstraint.candidates: Option<BTreeSet<String>> — None
  preserves pre-mq.2 single-class semantics; Some(set) carries the
  multi-candidate residual that discharge refines.
- ResidualConstraint visibility bumped pub(crate) → pub for
  unit-test crate access.

New helpers:
- MethodDispatchOutcome enum + pure resolve_method_dispatch
  implementing the spec's 5-step rule (qualifier → singleton →
  type-driven filter → constraint-driven filter → Multi for
  discharge-time refinement).
- parse_method_qualifier splits Term::Var.name into
  (method_name, optional_qualifier_prefix) at the last dot.
- RefineOutcome enum + refine_multi_candidate_residual for
  discharge-time refinement.
- resolve_residual_class_for_mono wires the refinement into mono's
  collect_residuals_ordered residual-to-target mapping.

Synth Var-arm class-method branch rewritten via parse_method_qualifier
with inner-dot gate (qualifier must be <module>.<Class>; single-dot
names fall through to the existing qualified-fn path).

Constraint-discharge in check_fn uses expanded (post-superclass-
expansion) constraints for the rigid-var path — sounder than raw
declared_constraints.

Plan-invented format_type_for_display replaced with the existing
ailang_core::pretty::type_to_string (one less duplicate).

Synth-time declared_constraints: &[] is a deliberate gap documented
as known debt — load-bearing only post-mq.3 for the rigid-var
fallback (env-plumbing the active fn's constraints into the Var
arm is a ~10-line edit slated for mq.3).

9/9 tasks, 539 tests green (was 520 pre-mq.2; +15 mq.2 unit tests +
4 pre-existing). bench/compile_check.py + cross_lang.py clean;
bench/check.py 1 regression (latency noise — runtime cannot be
touched by a typecheck-side iter).
This commit is contained in:
2026-05-13 01:40:42 +02:00
parent e9e45c77af
commit 2e6a4ca200
7 changed files with 1203 additions and 19 deletions
+169
View File
@@ -0,0 +1,169 @@
# iter mq.2 — Type-driven dispatch mechanism (installed, not yet exercised)
**Date:** 2026-05-13
**Started from:** e9e45c77affa635652505cc937b77b9349d18c8e
**Status:** DONE
**Tasks completed:** 9 of 9
## Summary
mq.2 installs the type-driven dispatch infrastructure that mq.3 will
exercise end-to-end once `MethodNameCollision` retires. Three new
`CheckError` variants (`AmbiguousMethodResolution`, `UnknownClass`,
plus an additive `candidate_classes` field on `NoInstance`); a new
workspace-flat `method_to_candidate_classes` index on `Env` (inverse
of `class_methods`); an extended `ResidualConstraint` carrying an
optional `candidates: Option<BTreeSet<String>>` for the multi-
candidate dispatch path; a pure `resolve_method_dispatch` helper
implementing the spec's 5-step rule (qualifier → singleton →
type-driven filter → constraint-driven filter → `Multi` for discharge-
time refinement); a `refine_multi_candidate_residual` helper for
discharge / mono refinement; and a `resolve_residual_class_for_mono`
helper that wires the refinement into mono's residual-to-target
mapping. The synth `Term::Var` arm class-method branch is rewritten
to consult the new index via `parse_method_qualifier`, with a gate
that prevents capturing the existing cross-module-fn path
(`<module>.<fn>`) by requiring class-qualifiers to carry an inner
dot (per mq.1's canonical-form rule, class qualifiers are always
`<module>.<Class>`).
With `MethodNameCollision` still active, the new multi-candidate
branch is exercised exclusively by 15 unit tests: 6 in
`tests/method_dispatch_pin.rs` (the 5-step rule's six cases), 6 in
`lib.rs` `mod tests` (the new `CheckError` variants + the
`ResidualConstraint`/`Env` field shapes + the discharge-side
refinement), and 3 in `mono.rs` `mod tests` (the mono-side helper).
Real workspaces continue producing single-class residuals
(`candidates: None`), and every pre-mq.2 fixture typechecks
unchanged.
## Per-task notes
- iter mq.2.1: Two new `CheckError` variants
`AmbiguousMethodResolution` and `UnknownClass`, added as siblings
after `NoInstance`. `code()` + `ctx()` table entries; module-doc
list in `diagnostic.rs` extended. 2 RED-then-GREEN smoke tests.
- iter mq.2.2: `NoInstance` extended with an additive
`candidate_classes: Vec<String>` field. `ctx()` emits the field
only when non-empty (preserves pre-mq.2 JSON shape). 2 tests:
with + without populated candidates.
- iter mq.2.3: `ResidualConstraint` extended with `candidates:
Option<BTreeSet<String>>`. Struct bumped to `pub` (was
`pub(crate)`) for unit-test access from the dispatch-pin / Task 7
+ 8 tests. Single existing construction site updated with
`candidates: None`. 1 smoke test.
- iter mq.2.4: `Env.method_to_candidate_classes:
BTreeMap<String, BTreeSet<String>>` built workspace-flat in
`build_check_env` alongside the existing `class_methods` insert
(coalesced into one loop). 1 in-test fixture round-trip.
- iter mq.2.5: `MethodDispatchOutcome` enum + pure
`resolve_method_dispatch` helper. 6 unit tests cover unique
candidate, qualifier-match, qualifier-no-match (UnknownClass),
type-driven narrow-to-one, constraint-driven narrow-to-one
(rigid-var fallback), and true ambiguity. Helper uses
`ailang_core::pretty::type_to_string` for the at_type rendering
rather than the plan's invented `format_type_for_display` (one
fewer duplicate; the canonical pretty-printer is already in use
across other diagnostic surfaces).
- iter mq.2.6: Synth `Term::Var` arm class-method branch rewritten
to consult `method_to_candidate_classes` via
`parse_method_qualifier`. Branch gate: qualifier must be either
absent (bare form `"show"`) or contain an inner dot
(`<module>.<Class>` per mq.1's canonical-form rule). 1-dot names
like `std_list.length` fall through to the existing qualified-fn
path. Plan's `active_declared_constraints` plumbing intentionally
skipped at synth time — synth's Var arm does not carry the body's
declared constraints; the constraint-driven filter runs at
discharge time (Task 7) where `expanded` is in scope. With
`MethodNameCollision` still active, the candidate set is always
singleton at this branch, so the `&[]` synth-time filter is
load-bearing only post-mq.3 and only for the rigid-var fallback;
on the post-mq.3 multi-candidate fully-concrete path discharge
will refine the residual on `r_ty` against the workspace registry.
- iter mq.2.7: `RefineOutcome` enum + `refine_multi_candidate_residual`
helper. Wired into `check_fn`'s residual loop BEFORE the existing
single-class discharge — multi-candidate residuals refine first,
`Resolved` falls through to `continue`, error variants return.
Used `expanded` (already-superclass-expanded declared constraints)
for the rigid-var path, sounder than `declared_constraints` direct.
3 unit tests for the discharge path.
- iter mq.2.8: `resolve_residual_class_for_mono` helper in mono.rs
+ 3 unit tests in a new `mod tests` block. Mono's residual-to-
target mapping (`collect_residuals_ordered`) now calls the helper
before constructing the registry key; resolved-class overrides
the residual's tentative `r.class` in `MonoTarget::ClassMethod`.
Single-class residuals flow through unchanged.
- iter mq.2.9: full `cargo test --workspace` 539 passed / 0 failed
(was 520 at start of mq.2; +19 = 15 new mq.2 tests + 4 pre-
existing mq.1 follow-on additions in cross-language test files
already shipped). All pre-mq.2 fixtures (prelude, mq1_xmod,
eq_ord_polymorphic) typecheck unchanged. Bench results:
`compile_check.py` 0 regressed / 0 improved / 24 stable;
`cross_lang.py` 0 regressed / 0 improved / 25 stable;
`check.py` 1 regressed / 2-4 improved / 58-60 stable (latency
noise — different metric regressed between two runs, runtime
benches cannot be touched by a typecheck-side iter). Prelude
roundtrip clean.
## Concerns
- **Helper-cost: `parse_method_qualifier` does an `rfind('.')` on
every `Term::Var` lookup that reaches the class-method branch.**
Today this is bounded by the per-Var-arm cost (already O(name-
length) due to the dot-counting and unification ladder), so it's
noise. If profiling ever shows the Var arm hot, the right fix is
to cache `(method_name, qualifier_opt)` on the Var node rather
than recompute — but that's a Term struct change and out of
scope for mq.2.
- **`pub` bump on `ResidualConstraint`.** The unit-test crate
(`tests/method_dispatch_pin.rs`) needs to construct
`ResidualConstraint` for Task 5; Task 7's in-lib tests also
reference it. Bumping from `pub(crate)` to `pub` is the minimum
edit; the struct semantically belongs at the workspace boundary
anyway (the spec's published shape for what the dispatcher
produces). No risk: no external crate depends on `ailang-check`
outside this workspace.
- **`MissingConstraint.class` field for multi-candidate rigid-var
case is best-effort.** When `refine_multi_candidate_residual`
emits `MissingConstraint`, the surfaced `class` is the first
candidate (BTreeSet order). The diagnostic ctx still names the
method, the type, and (via the diagnostic Display) the full
candidate set is reachable; the `class` is just a hint. Real
workspaces never reach this branch pre-mq.3.
## Known debt
- **mq.3 retires `MethodNameCollision` and exercises the
multi-candidate path end-to-end.** The unit tests in this iter
are the only coverage of the new branches until then. mq.3 will
add cross-class-collision fixtures (e.g. two `Show` classes in
separate modules) and the existing tests should continue to pass
unchanged.
- **`class_methods` keyed by method name only.** Pre-mq.3 invariant
guarantees `env.class_methods.get(method_name)` is unambiguous;
post-mq.3 this lookup needs to be reworked (likely keyed by
`(class, method)` or by `method` returning a list). The synth
branch's `expect("invariant")` documents the dependency. Not
load-bearing in mq.2.
- **Synth-time `declared_constraints: &[]` in the Var arm rewrite.**
This is correct for pre-mq.3 (singleton candidate set never
needs filtering) but means the post-mq.3 constraint-driven
filter cannot fire at synth time. The branch will need
`env.active_declared_constraints` plumbed through (Env field
+ `check_fn` initialization) before mq.3's first multi-candidate
workspace with declared constraints. Cost: ~10-line edit.
## Files touched
- crates/ailang-check/src/diagnostic.rs
- crates/ailang-check/src/lib.rs
- crates/ailang-check/src/mono.rs
- crates/ailang-check/tests/method_dispatch_pin.rs (new)
## Stats
bench/orchestrator-stats/2026-05-13-iter-mq.2.json
+1
View File
@@ -43,3 +43,4 @@
- 2026-05-12 — audit-ct-tidy: milestone close (ct-tidy) — architect drift fixed inline as `ctt.tidy` (3 doc-side edits: DESIGN.md §"Class-schema diagnostics" drops the retired `KindMismatch` bullet + adds successor paragraph naming `BareCrossModuleTypeRef`; DESIGN.md §"Higher-kinded class params" rewritten to name `BareCrossModuleTypeRef` from canonical-form validation instead; roadmap.md two P2 todos struck `[x]` with forward-reference to ctt.3 and ctt.2). Bench mixed (check.py exit 1: bump_s persistence on `bench_list_sum` second consecutive sighting at +12.23% → +12.60%; two new first-sighting rc-cohort max_us latency regressions classified as noise pending next audit; the recurring 3-audit `latency.explicit_at_rc` improvement cluster narrowed to within tolerance this audit, withdrawing the ratify-pending plan from audit-eob — the meaningful shrinkage is itself attribution evidence that the cluster is noise-class not signal-class), baseline pristine for the 4th consecutive audit → 2026-05-12-audit-ct-tidy.md
- 2026-05-12 — iter 24.1: `bool_to_str` + `str_clone` runtime + codegen wiring — 2 new C functions in `runtime/str.c` (slab-allocating heap-Str primitives via existing `str_alloc`), lockstep checker + synth.rs installs with `ret_mode: Own`, 2 IR-header declares + 2 `lower_app` arms + `is_static_callee` whitelist extension, 5 IR snapshots regen (2 declares × 5 files), 9 new tests (2 builtins-install unit + 2 IR-shape pin + 5 E2E all green), pre-existing-drift fix included (int_to_str row added to `builtins.rs::list()`). One substantive deviation: builtin-signatures registered in `uniqueness.rs::infer_module` + `linearity.rs::check_module_with_visible` (8 LOC × 2 files) so `str_clone`'s `param_modes: [Borrow]` is visible to the App-arg walker; symmetric to 23.4-prep's class-method registration; necessary for the plan's literal `frees == 3` cross-realisation assertion. Full `cargo test --workspace` 513 passed, 0 failed. `bench/compile_check.py` + `bench/cross_lang.py` green → 2026-05-12-iter-24.1.md
- 2026-05-13 — iter mq.1: class-ref canonical-form extension + workspace-internal class-name qualification — three schema fields (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`) move bare → canonical (bare for same-module, `<module>.<Class>` for cross-module) symmetric to ct.1's `Type::Con.name` rule; `ClassDef.name` stays bare (defining site, like `TypeDef.name`). `validate_canonical_type_names` gains three field-walks via new `check_class_ref` helper plus two sibling diagnostics `BareCrossModuleClassRef` / `BadCrossModuleClassRef`; `check_class_name_fields` narrowed to `ClassDef.name`-only. Workspace-internal class-name keys all qualified: `class_def_module`, `class_by_name`, registry `entries.0`, `ClassMethodEntry.class_name`, `class_superclasses`, mono's `class_index`, `Origin::Class.class_name`. New `qualify_class_ref` helper in `workspace.rs` plus sibling `qualify_class_ref_in_check` in `ailang-check`. Two new positive on-disk fixtures (`mq1_xmod_constraint_class{,_dep}`). Recon claim that all existing fixtures' class-refs are intra-module was empirically wrong — 5 test_22b* (`orphan_third`, `dup_a/b/entry`, `unbound_constraint_var`) + 5 other (`eq_ord_polymorphic`, `eq_ord_user_adt`, `cmp_max_smoke`, `ctt2_collision_{lib,main}`) fixtures required minimal canonical-form migration. Duplicate-instance test architecturally restructured: post-mq.1 orphan-freedom makes two-modules-on-same-`(class, type)` structurally impossible (any second instance in a non-owning module fires `OrphanInstance` first); new `test_22b1_dup_same_module.ail.json` lands both instances in the class's module — the only post-mq.1 way to land two on the same canonical key. Plan defects fixed inline: `Origin::Class.class_name` had one construction site not two (plan recon off-by-one); `build_class_index` in `mono.rs` needed qualifying (plan said "no code change needed"); `build_module_globals` needed a `Def::Instance` carve-out for qualified `inst.class`; `check_fn`'s declared-constraint matching needed inline canonical-form lifting; `NoInstance` Float-aware message arm needed `prelude.Eq`/`prelude.Ord` match; coherence type-leg needed qualified-head split-and-lookup. Tasks 3-6 ran as one coherent fix (Task 3 alone left tests RED). 7/7 tasks, 520 tests green, `bench/compile_check.py` + `bench/cross_lang.py` exit 0; `bench/check.py` exit 1 due to 4 improvements-beyond-tolerance (audit-ratifiable per convention, not a regression). `MethodNameCollision` + dispatch path unchanged; iters mq.2 + mq.3 land them → 2026-05-13-iter-mq.1.md
- 2026-05-13 — iter mq.2: type-driven dispatch mechanism installed (mechanism-before-exercise) — `Env.method_to_candidate_classes` workspace-flat inverse index built alongside `class_methods`; two new `CheckError` variants `AmbiguousMethodResolution` + `UnknownClass` (Display+code+ctx) plus additive `NoInstance.candidate_classes` field; `ResidualConstraint` extended with `candidates: Option<BTreeSet<String>>` (visibility bumped `pub(crate)``pub` for test-crate access); pure `resolve_method_dispatch` helper implementing the spec's 5-step rule (qualifier → singleton → type-driven filter → constraint-driven filter → `Multi` for discharge-time refinement); synth Var-arm class-method branch rewritten via `parse_method_qualifier` with inner-dot gate (qualifier must be `<module>.<Class>` form, single-dot names like `std_list.length` fall through to the existing qualified-fn path); `refine_multi_candidate_residual` wired into `check_fn` discharge using `expanded` (post-superclass-expansion constraints) for the rigid-var path; `resolve_residual_class_for_mono` wired into mono's `collect_residuals_ordered` residual-to-target mapping. With `MethodNameCollision` still gating real workspaces, the new multi-candidate branches are exercised exclusively by 15 unit tests: 6 in `tests/method_dispatch_pin.rs` covering the 5-step rule's six cases, 6 in `lib.rs` `mod tests` covering variants + field shapes + discharge refinement, 3 in `mono.rs` `mod tests` covering the mono helper. Real workspaces continue producing single-class residuals (`candidates: None`); every pre-mq.2 fixture typechecks unchanged. Plan-invented `format_type_for_display` replaced with `ailang_core::pretty::type_to_string` (one less duplicate). Synth-time `declared_constraints: &[]` is a deliberate gap documented as known debt — load-bearing only post-mq.3 for the rigid-var fallback (env-plumbing the active fn's constraints into the Var arm is a ~10-line edit slated for mq.3). 9/9 tasks, 539 tests green (was 520 at start of mq.2; +19 = 15 new + 4 pre-existing); `bench/compile_check.py` + `cross_lang.py` clean; `bench/check.py` 1 regression (latency noise, runtime cannot be touched by typecheck-side iter). `MethodNameCollision` retirement lands in mq.3 → 2026-05-13-iter-mq.2.md