Files
AILang/docs/plans/0053-mq.2.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

60 KiB

Iter mq.2 — Type-driven dispatch mechanism (installed, not yet exercised) — Implementation Plan

Parent spec: docs/specs/0023-module-qualified-class-names.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Install the type-driven dispatch infrastructure — new method_to_candidate_classes workspace-flat index on Env, extended ResidualConstraint carrying an optional candidate-class set, two new CheckError variants (AmbiguousMethodResolution + UnknownClass), additive NoInstance.candidate_classes field, rewritten synth Var-arm class-method branch per Architecture's 5-step rule, constraint-discharge refinement handling the multi-candidate residual, mono's residual mapping handling the same. Exercised in this iter exclusively by unit tests on the new dispatch-resolution helper; end-to-end multi-class workspaces remain gated by MethodNameCollision until iter mq.3.

Architecture: Synth's Term::Var arm at lib.rs:2028 consults a new workspace-flat method_to_candidate_classes: BTreeMap<String, BTreeSet<QualifiedClassName>> (built in build_check_env alongside the workspace-flat class_methods aggregation). For the bare-method case the candidate set is either singleton (today's invariant) or multi (future post-mq.3); both cases push a ResidualConstraint with an optional candidates field. At fn-body-end discharge, multi- candidate residuals are refined by either type-driven filter (concrete type_ against (class, type_hash) ∈ Registry) or constraint-driven filter (rigid-var type_ against active declared constraints). Single survivor → discharge as today; multiple → AmbiguousMethodResolution or MissingConstraint (rigid var); zero → NoInstance or MissingConstraint. Mono's residual mapping mirrors the discharge refinement to land a unique MonoTarget::ClassMethod per call site.

Tech Stack: ailang-check (lib.rs, mono.rs, diagnostic.rs), unit-test sibling crate (crates/ailang-check/tests/method_dispatch_pin.rs).

Pre-flight notes (from recon, settled by Boss):

  • Multi-candidate residual representation → extend ResidualConstraint with candidates: Option<BTreeSet<String>> (None = single-class today path; Some = multi-candidate post-mq.3 path).
  • method_to_candidate_classes lives on Env (workspace-flat). Per- module form skipped because candidate sets span the workspace by definition.
  • NoInstance.candidate_classes wired through to_diagnostic in this iter (the field is added with rendering at the same time).
  • qualify_class_ref_in_check consolidation deferred per mq.1 known-debt entry; not in scope.
  • Mq.1 invariant: synth Var-arm at lib.rs:2028 already consumes qualified cm.class_name; the rewrite in this plan does NOT re-qualify anything, only restructures the resolution logic.

Task 1: Add CheckError::AmbiguousMethodResolution + CheckError::UnknownClass

Files:

  • Modify: crates/ailang-check/src/lib.rs:355-622 (CheckError variants + code() + ctx())

  • Modify: crates/ailang-check/src/diagnostic.rs:64-79 (module-doc code list)

  • Test: inline in crates/ailang-check/src/lib.rs mod tests (Display + code() smoke)

  • Step 1: Write RED tests for the new variants' code() and Display

Append inside the existing mod tests block at the bottom of crates/ailang-check/src/lib.rs:

/// mq.2.1: `AmbiguousMethodResolution` is the new check-time
/// diagnostic for multi-candidate residuals that survive type-driven
/// filtering at a monomorphic call site. Verifies the variant is
/// constructible, its `code()` returns the structured-diagnostic key,
/// and the Display surface names the candidate classes.
#[test]
fn mq2_ambiguous_method_resolution_display_and_code() {
    let err = CheckError::AmbiguousMethodResolution {
        method: "show".to_string(),
        at_type: "Int".to_string(),
        candidate_classes: vec![
            "prelude.Show".to_string(),
            "userlib.Show".to_string(),
        ],
    };
    assert_eq!(err.code(), "ambiguous-method-resolution");
    let rendered = format!("{}", err);
    assert!(rendered.contains("show"), "Display must echo method, got: {rendered}");
    assert!(rendered.contains("prelude.Show"), "Display must name candidate, got: {rendered}");
    assert!(rendered.contains("userlib.Show"), "Display must name candidate, got: {rendered}");
}

/// mq.2.1: `UnknownClass` is the new check-time diagnostic for an
/// explicit class qualifier in `Term::Var.name` that names a
/// qualified class not in the workspace.
#[test]
fn mq2_unknown_class_display_and_code() {
    let err = CheckError::UnknownClass {
        name: "unknownlib.Show".to_string(),
    };
    assert_eq!(err.code(), "unknown-class");
    let rendered = format!("{}", err);
    assert!(rendered.contains("unknownlib.Show"), "Display must echo the qualified name, got: {rendered}");
}
  • Step 2: Run tests to confirm RED

Run: cargo test --workspace -p ailang-check mq2_ambiguous_method_resolution_display_and_code mq2_unknown_class_display_and_code

Expected: compile error (variants don't exist).

  • Step 3: Add the two variants to CheckError

In crates/ailang-check/src/lib.rs, find the existing NoInstance variant at lib.rs:567-572. Add two siblings immediately after:

/// mq.2: a monomorphic call site `<method> x` with multiple
/// candidate classes (each declaring `<method>` and each having an
/// instance for `x`'s concrete type) cannot be resolved unambiguously.
/// The LLM-author writes an explicit qualifier
/// (`<class-qualifier>.<method> x`) to disambiguate.
#[error(
    "method `{method}` at type `{at_type}` is ambiguous: classes \
    {candidate_classes:?} all declare it and provide an instance. \
    Disambiguate with `<ClassQualifier>.{method}`."
)]
AmbiguousMethodResolution {
    method: String,
    at_type: String,
    candidate_classes: Vec<String>,
},

/// mq.2: an explicit class qualifier in `Term::Var.name` names a
/// qualified class that's not in the workspace registry.
#[error("class `{name}` is not declared in any module of this workspace")]
UnknownClass {
    name: String,
},
  • Step 4: Add code() table entries

Locate the code() impl on CheckError at lib.rs:617-620 (the match arm table that returns the structured-diagnostic-key string). Add two arms:

CheckError::AmbiguousMethodResolution { .. } => "ambiguous-method-resolution",
CheckError::UnknownClass { .. } => "unknown-class",
  • Step 5: Add ctx() table entries

Locate the ctx() impl on CheckError at lib.rs:658-663 (the match arm table that renders the structured-diagnostic context JSON). Add two arms returning a serde_json::Value:

CheckError::AmbiguousMethodResolution { method, at_type, candidate_classes } => {
    serde_json::json!({
        "method": method,
        "at_type": at_type,
        "candidate_classes": candidate_classes,
    })
}
CheckError::UnknownClass { name } => {
    serde_json::json!({ "name": name })
}

(Match the exact return-type and shape of the existing ctx() arms — the recon notes MissingConstraint and NoInstance arms at lib.rs:658-663 as the pattern.)

  • Step 6: Update diagnostic.rs module doc-comment

In crates/ailang-check/src/diagnostic.rs:64-79 (the module-level doc-comment listing stable diagnostic codes), add two entries to the list (keep alphabetical or insertion-order with existing pattern):

//! - `ambiguous-method-resolution` — mq.2: bare method call has
//!   multiple candidate classes after type-driven filter.
//! - `unknown-class` — mq.2: explicit class qualifier names a class
//!   not in the workspace registry.
  • Step 7: Run tests to confirm GREEN

Run: cargo test --workspace -p ailang-check mq2_ambiguous_method_resolution_display_and_code mq2_unknown_class_display_and_code

Expected: both PASS.

  • Step 8: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. Only additive variants; no semantic change.


Task 2: Extend CheckError::NoInstance with optional candidate_classes

Files:

  • Modify: crates/ailang-check/src/lib.rs:567-572 (NoInstance struct + Display)

  • Modify: crates/ailang-check/src/lib.rs:1697-1702 (existing NoInstance construction)

  • Modify: crates/ailang-check/src/lib.rs:658-663 + 718-728 (ctx() + Float-aware addendum)

  • Test: inline in crates/ailang-check/src/lib.rs mod tests

  • Step 1: Write a RED test for the extended Display

Append in mod tests:

/// mq.2.2: `NoInstance` carries an optional `candidate_classes`
/// list (additive in mq.2). When non-empty, the Display surfaces
/// it; when empty, the Display is unchanged from pre-mq.2 form
/// (backwards compatible for single-class call sites).
#[test]
fn mq2_no_instance_with_candidate_classes_renders() {
    let err = CheckError::NoInstance {
        class: "prelude.Show".to_string(),
        method: "show".to_string(),
        at_type: "MyType".to_string(),
        candidate_classes: vec!["prelude.Show".to_string()],
    };
    let rendered = format!("{}", err);
    assert!(rendered.contains("show"), "Display must echo method, got: {rendered}");
    assert!(rendered.contains("MyType"), "Display must echo type, got: {rendered}");
}

/// mq.2.2: empty `candidate_classes` keeps the existing single-class
/// Display shape — surface mentions the resolved class only.
#[test]
fn mq2_no_instance_empty_candidates_back_compat() {
    let err = CheckError::NoInstance {
        class: "prelude.Show".to_string(),
        method: "show".to_string(),
        at_type: "MyType".to_string(),
        candidate_classes: vec![],
    };
    let rendered = format!("{}", err);
    assert!(rendered.contains("prelude.Show"), "Display must echo class, got: {rendered}");
}
  • Step 2: Run tests to confirm RED

Run: cargo test --workspace -p ailang-check mq2_no_instance

Expected: compile error — candidate_classes field does not exist on NoInstance.

  • Step 3: Add the field to NoInstance

In crates/ailang-check/src/lib.rs:567-572, find the NoInstance variant. Add the optional field (as a Vec<String> defaulting to empty rather than Option<Vec<String>> to keep construction simpler):

/// `MissingConstraint`: when the residual type is concrete, the fn
/// — it can only be discharged by an existing instance. Without a
/// matching instance the call is rejected as `NoInstance`. mq.2 adds
/// the optional `candidate_classes` field: empty preserves the
/// pre-mq.2 single-class Display; non-empty surfaces the wider
/// candidate set when the bare-method dispatch path failed at the
/// concrete-type filter.
#[error(
    "no instance found for class `{class}` at type `{at_type}` \
    (method `{method}` invoked here)"
)]
NoInstance {
    class: String,
    method: String,
    at_type: String,
    #[serde(default)]
    candidate_classes: Vec<String>,
},

(If #[serde(default)] is not applicable because the variant isn't serde-derived, omit it. Match the existing struct shape exactly.)

  • Step 4: Update the single existing construction site

At lib.rs:1697-1702 (per recon), the existing single NoInstance construction in check_fn's residual-discharge loop. Add the empty field:

return Err(CheckError::NoInstance {
    class: r.class.clone(),
    method: r.method.clone(),
    at_type: format_type_for_display(&r_ty_norm),
    candidate_classes: vec![],
});

Keep all other construction sites (if any are found via grep -n "NoInstance {" crates/ailang-check/src/) updated the same way — add candidate_classes: vec![] to each.

  • Step 5: Update ctx() and Float-aware addendum

In crates/ailang-check/src/lib.rs:658-663, update the NoInstance arm to include the new field in the rendered JSON. In lib.rs:718-728 (the Float-aware addendum that branches on class == "prelude.Eq" || class == "prelude.Ord"), no change needed — the addendum reads class only.

CheckError::NoInstance { class, method, at_type, candidate_classes } => {
    let mut obj = serde_json::json!({
        "class": class,
        "method": method,
        "at_type": at_type,
    });
    if !candidate_classes.is_empty() {
        obj["candidate_classes"] = serde_json::json!(candidate_classes);
    }
    obj
}
  • Step 6: Run the mq.2.2 tests to confirm GREEN

Run: cargo test --workspace -p ailang-check mq2_no_instance

Expected: both PASS.

  • Step 7: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. Additive field with empty default preserves all existing semantics.


Task 3: Extend ResidualConstraint with Option<BTreeSet<String>> candidates

Files:

  • Modify: crates/ailang-check/src/lib.rs:1737-1749 (ResidualConstraint struct)

  • Modify: lib.rs construction sites for ResidualConstraint (grep for ResidualConstraint {)

  • Test: inline in crates/ailang-check/src/lib.rs mod tests

  • Step 1: Write a RED smoke test for the extended struct

Append in mod tests:

/// mq.2.3: `ResidualConstraint` carries an optional candidate-class
/// set for the multi-candidate dispatch path. `None` preserves the
/// pre-mq.2 single-class semantics (the `class` field is the
/// resolved class). `Some(...)` carries the candidate set; the
/// `class` field's content is the first-of-set tentatively (or a
/// sentinel — implementer choice as long as the discharge path
/// reads from `candidates` when present).
#[test]
fn mq2_residual_constraint_candidates_field_exists() {
    let single = ResidualConstraint {
        class: "prelude.Eq".to_string(),
        type_: Type::Con { name: "Int".to_string(), args: vec![] },
        method: "eq".to_string(),
        candidates: None,
    };
    assert!(single.candidates.is_none());

    let mut multi_set = std::collections::BTreeSet::new();
    multi_set.insert("prelude.Show".to_string());
    multi_set.insert("userlib.Show".to_string());
    let multi = ResidualConstraint {
        class: "prelude.Show".to_string(),
        type_: Type::Con { name: "Int".to_string(), args: vec![] },
        method: "show".to_string(),
        candidates: Some(multi_set.clone()),
    };
    assert_eq!(multi.candidates, Some(multi_set));
}
  • Step 2: Run test to confirm RED

Run: cargo test --workspace -p ailang-check mq2_residual_constraint_candidates_field_exists

Expected: compile error — candidates field does not exist.

  • Step 3: Add the field to ResidualConstraint

At lib.rs:1737-1749, find the existing ResidualConstraint struct. Add the field (with doc-comment):

#[derive(Debug, Clone)]
pub(crate) struct ResidualConstraint {
    pub class: String,
    pub type_: Type,
    pub method: String,
    /// mq.2: candidate-class set for the multi-candidate dispatch
    /// path. `None` ⇒ single-class semantics (pre-mq.2 behaviour);
    /// the `class` field is the resolved class. `Some(set)` ⇒
    /// multi-candidate residual; discharge filters the set at fn-
    /// body-end. When `Some`, the `class` field carries a tentative
    /// value (typically the first set element) that discharge
    /// overwrites on resolution.
    pub candidates: Option<BTreeSet<String>>,
}

(Add use std::collections::BTreeSet; at the top of the file if not already imported.)

  • Step 4: Update existing ResidualConstraint construction sites to pass candidates: None

Run: grep -n "ResidualConstraint {" crates/ailang-check/src/lib.rs

Each site must add the new field as candidates: None,. The known sites from recon: lib.rs:2051-2055 (synth Var-arm class-method push). Verify with grep and update each.

Example:

residuals.push(ResidualConstraint {
    class: cm.class_name.clone(),
    type_: fresh,
    method: name.clone(),
    candidates: None,
});
  • Step 5: Run the mq.2.3 test to confirm GREEN

Run: cargo test --workspace -p ailang-check mq2_residual_constraint_candidates_field_exists

Expected: PASS.

  • Step 6: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. All existing residual constructions now pass candidates: None, single-class discharge path unchanged.


Task 4: Build method_to_candidate_classes on Env in build_check_env

Files:

  • Modify: crates/ailang-check/src/lib.rs:2904 (Env struct — add field)

  • Modify: crates/ailang-check/src/lib.rs:1291-1295 (build_check_env aggregation site)

  • Test: inline in crates/ailang-check/src/lib.rs mod tests

  • Step 1: Write a RED test for the new field

Append in mod tests:

/// mq.2.4: `Env` carries a workspace-flat
/// `method_to_candidate_classes: BTreeMap<String, BTreeSet<String>>`
/// inverse map of `class_methods`. Today's `MethodNameCollision`
/// invariant guarantees each set is singleton; post-mq.3 the cardinality
/// > 1 case becomes legal.
#[test]
fn mq2_env_method_to_candidate_classes_built() {
    // Build a minimal workspace with one class declaring one method.
    let mut modules = BTreeMap::new();
    modules.insert(
        "m".to_string(),
        Module {
            name: "m".to_string(),
            imports: vec![],
            defs: vec![Def::Class(ClassDef {
                name: "MyShow".to_string(),
                param: "a".to_string(),
                superclass: None,
                methods: vec![ClassMethod {
                    name: "myshow".to_string(),
                    ty: Type::Fn {
                        params: vec![Type::Var { name: "a".to_string() }],
                        param_modes: vec![ParamMode::Borrow],
                        ret: Box::new(Type::Con { name: "Str".to_string(), args: vec![] }),
                        effects: vec![],
                    },
                    default: None,
                }],
                doc: None,
            })],
        },
    );
    let ws = build_workspace_for_test(modules); // helper or inline equivalent
    let env = build_check_env(&ws);
    let candidates = env.method_to_candidate_classes.get("myshow")
        .expect("method_to_candidate_classes must contain `myshow`");
    assert!(candidates.contains("m.MyShow"), "candidates: {candidates:?}");
    assert_eq!(candidates.len(), 1, "MethodNameCollision invariant: singleton");
}

(If build_workspace_for_test does not exist, inline the workspace construction using the same pattern as other in-file tests that construct synthetic Workspace values. The implementer adapts to the existing in-test workspace-construction pattern.)

  • Step 2: Run test to confirm RED

Run: cargo test --workspace -p ailang-check mq2_env_method_to_candidate_classes_built

Expected: compile error — method_to_candidate_classes field does not exist on Env.

  • Step 3: Add the field to Env

At lib.rs:2904 (per recon, the Env struct holds class_methods). Add a sibling field immediately after:

pub(crate) struct Env {
    // ...existing fields...
    pub(crate) class_methods: BTreeMap<String, ClassMethodEntry>,
    /// mq.2: workspace-flat inverse of `class_methods` — for each
    /// method name, the set of qualified class names that declare
    /// it. Today's `MethodNameCollision` invariant guarantees each
    /// set is singleton; mq.3 lifts this and cardinality > 1
    /// becomes legal. Synth's `Term::Var` arm consults this index
    /// per the 5-step rule (spec §Architecture).
    pub(crate) method_to_candidate_classes: BTreeMap<String, BTreeSet<String>>,
    // ...remaining fields...
}

(Insert at the exact line determined by grep -n "pub(crate) class_methods" crates/ailang-check/src/lib.rs.)

  • Step 4: Populate the new field in build_check_env

At lib.rs:1291-1295 (per recon, the workspace-flat aggregation loop that builds env.class_methods), add a parallel loop or extend the existing one to populate method_to_candidate_classes:

let mut method_to_candidate_classes: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (_mod_name, mg) in &per_module_globals {
    for (method_name, entry) in &mg.class_methods {
        method_to_candidate_classes
            .entry(method_name.clone())
            .or_default()
            .insert(entry.class_name.clone());
    }
}

Pass method_to_candidate_classes into the Env constructor at the end of build_check_env. Match the existing pattern that passes class_methods into Env.

  • Step 5: Run the mq.2.4 test to confirm GREEN

Run: cargo test --workspace -p ailang-check mq2_env_method_to_candidate_classes_built

Expected: PASS.

  • Step 6: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. New field built but not yet consumed.


Task 5: New resolve_method_dispatch helper + six unit-test cases

Files:

  • Modify: crates/ailang-check/src/lib.rs (add helper function near ResidualConstraint)

  • Create: crates/ailang-check/tests/method_dispatch_pin.rs (six unit tests on the helper)

  • Step 1: Write the six unit tests (RED-first)

Create crates/ailang-check/tests/method_dispatch_pin.rs:

//! mq.2.5: pin tests on `resolve_method_dispatch` — the new
//! dispatch-resolution helper that synth's `Term::Var` arm consults
//! per the spec's 5-step rule.
//!
//! Six cases:
//! 1. Unique candidate.
//! 2. Multi + explicit qualifier matching one.
//! 3. Multi + explicit qualifier matching none → UnknownClass.
//! 4. Multi + type-driven filter narrows to one.
//! 5. Multi + constraint-driven filter narrows to one (rigid var case).
//! 6. Multi + true ambiguity → AmbiguousMethodResolution.

use ailang_check::{resolve_method_dispatch, MethodDispatchOutcome};
use ailang_check::ResidualConstraint; // if needed
use ailang_core::ast::Type;
use ailang_core::canonical;
use std::collections::{BTreeMap, BTreeSet};

fn registry_with(entries: &[(&str, &str)]) -> BTreeMap<(String, String), ()> {
    // Returns a synthetic registry: keys are (qualified_class, type_hash_string)
    // values are unit (instance presence flag).
    let mut reg = BTreeMap::new();
    for (cls, ty_name) in entries {
        let t = Type::Con { name: ty_name.to_string(), args: vec![] };
        let h = canonical::type_hash(&t);
        reg.insert((cls.to_string(), h), ());
    }
    reg
}

fn candidates_of(classes: &[&str]) -> BTreeSet<String> {
    classes.iter().map(|s| s.to_string()).collect()
}

/// Case 1: unique candidate → returns that class.
#[test]
fn case1_unique_candidate_resolves() {
    let candidates = candidates_of(&["prelude.Eq"]);
    let registry = registry_with(&[("prelude.Eq", "Int")]);
    let arg_ty = Type::Con { name: "Int".to_string(), args: vec![] };
    let result = resolve_method_dispatch(
        /*method*/ "eq",
        /*qualifier_prefix*/ None,
        /*candidates*/ &candidates,
        /*concrete_arg_type*/ Some(&arg_ty),
        /*declared_constraints*/ &[],
        /*registry*/ &registry,
    );
    assert_eq!(result, MethodDispatchOutcome::Resolved("prelude.Eq".to_string()));
}

/// Case 2: multi + explicit qualifier matching one → returns that class.
#[test]
fn case2_qualifier_matches_one() {
    let candidates = candidates_of(&["prelude.Show", "userlib.Show"]);
    let registry = registry_with(&[
        ("prelude.Show", "Int"),
        ("userlib.Show", "Int"),
    ]);
    let arg_ty = Type::Con { name: "Int".to_string(), args: vec![] };
    let result = resolve_method_dispatch(
        "show",
        Some("userlib.Show"),
        &candidates,
        Some(&arg_ty),
        &[],
        &registry,
    );
    assert_eq!(result, MethodDispatchOutcome::Resolved("userlib.Show".to_string()));
}

/// Case 3: multi + explicit qualifier matching none → UnknownClass.
#[test]
fn case3_qualifier_matches_none_unknown_class() {
    let candidates = candidates_of(&["prelude.Show", "userlib.Show"]);
    let registry = registry_with(&[("prelude.Show", "Int")]);
    let arg_ty = Type::Con { name: "Int".to_string(), args: vec![] };
    let result = resolve_method_dispatch(
        "show",
        Some("nonexistent.Show"),
        &candidates,
        Some(&arg_ty),
        &[],
        &registry,
    );
    assert_eq!(
        result,
        MethodDispatchOutcome::UnknownClass("nonexistent.Show".to_string()),
    );
}

/// Case 4: multi + type-driven filter narrows to one → returns that class.
#[test]
fn case4_type_driven_filter_narrows_to_one() {
    let candidates = candidates_of(&["prelude.Show", "userlib.Show"]);
    let registry = registry_with(&[("prelude.Show", "Int")]); // only prelude.Show has Show Int
    let arg_ty = Type::Con { name: "Int".to_string(), args: vec![] };
    let result = resolve_method_dispatch(
        "show",
        None,
        &candidates,
        Some(&arg_ty),
        &[],
        &registry,
    );
    assert_eq!(result, MethodDispatchOutcome::Resolved("prelude.Show".to_string()));
}

/// Case 5: multi + constraint-driven filter narrows to one (rigid var) → returns that class.
#[test]
fn case5_constraint_driven_filter_narrows_to_one() {
    let candidates = candidates_of(&["prelude.Show", "userlib.Show"]);
    let registry = registry_with(&[]);
    let rigid_a = Type::Var { name: "a".to_string() };
    use ailang_core::ast::Constraint;
    let declared = vec![Constraint {
        class: "prelude.Show".to_string(),
        type_: Type::Var { name: "a".to_string() },
    }];
    let result = resolve_method_dispatch(
        "show",
        None,
        &candidates,
        Some(&rigid_a), // rigid var, can't drive registry lookup
        &declared,
        &registry,
    );
    assert_eq!(result, MethodDispatchOutcome::Resolved("prelude.Show".to_string()));
}

/// Case 6: multi + true ambiguity → AmbiguousMethodResolution.
#[test]
fn case6_true_ambiguity() {
    let candidates = candidates_of(&["prelude.Show", "userlib.Show"]);
    let registry = registry_with(&[
        ("prelude.Show", "Int"),
        ("userlib.Show", "Int"),
    ]);
    let arg_ty = Type::Con { name: "Int".to_string(), args: vec![] };
    let result = resolve_method_dispatch(
        "show",
        None,
        &candidates,
        Some(&arg_ty),
        &[],
        &registry,
    );
    assert_eq!(
        result,
        MethodDispatchOutcome::Ambiguous {
            method: "show".to_string(),
            at_type: "Int".to_string(),
            candidates: vec!["prelude.Show".to_string(), "userlib.Show".to_string()],
        },
    );
}
  • Step 2: Run tests to confirm RED

Run: cargo test --workspace --test method_dispatch_pin

Expected: compile error — resolve_method_dispatch and MethodDispatchOutcome are not exported.

  • Step 3: Add the MethodDispatchOutcome type and the helper

In crates/ailang-check/src/lib.rs, near the ResidualConstraint declaration (lib.rs:1737), add:

/// mq.2: outcome of the dispatch-resolution helper. The synth Var-arm
/// consumer translates this enum into either a singleton
/// `ResidualConstraint` (Resolved) or a multi-candidate residual
/// (Multi) for discharge-time refinement, or a `CheckError` (Unknown
/// / Ambiguous).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MethodDispatchOutcome {
    /// Single class resolved unambiguously.
    Resolved(String),
    /// Explicit qualifier names a class not in the candidate set.
    UnknownClass(String),
    /// Bare-method call site survived both type-driven and constraint-
    /// driven filters with >1 candidate.
    Ambiguous {
        method: String,
        at_type: String,
        candidates: Vec<String>,
    },
    /// Bare-method call site with multiple candidates that need
    /// discharge-time refinement (rigid var type at synth time;
    /// concrete type only available at discharge).
    Multi {
        method: String,
        candidates: BTreeSet<String>,
    },
}

/// mq.2: resolve a method-call site per the spec's 5-step rule.
/// Pure function — no Env mutation, no residual emission.
///
/// `qualifier_prefix` is the dot-stripped class prefix (e.g.
/// `"prelude.Show"` for a `Term::Var.name == "prelude.Show.show"`).
/// `None` = bare method form.
///
/// `concrete_arg_type` is `Some` when synth's caller has the arg
/// type post-unification (App-arm); `None` when synth is at the
/// pre-App Var arm. When `None`, the helper does NOT apply type-
/// driven filtering at synth time.
///
/// `declared_constraints` are the active forall-bound constraints
/// in scope at the call site; consulted for the rigid-var fallback.
///
/// `registry` is the workspace registry keyed by `(qualified_class,
/// type_hash)` for instance-existence checks.
pub fn resolve_method_dispatch(
    method: &str,
    qualifier_prefix: Option<&str>,
    candidates: &BTreeSet<String>,
    concrete_arg_type: Option<&Type>,
    declared_constraints: &[ailang_core::ast::Constraint],
    registry: &BTreeMap<(String, String), ()>,
) -> MethodDispatchOutcome {
    // Step 3 (5-step rule): explicit qualifier present.
    if let Some(q) = qualifier_prefix {
        if candidates.contains(q) {
            return MethodDispatchOutcome::Resolved(q.to_string());
        }
        return MethodDispatchOutcome::UnknownClass(q.to_string());
    }

    // Step 4: bare-method form.
    if candidates.len() == 1 {
        return MethodDispatchOutcome::Resolved(
            candidates.iter().next().cloned().unwrap(),
        );
    }

    // Multi-candidate path.
    // Type-driven filter first if arg type is concrete (non-Var).
    let concrete = concrete_arg_type
        .filter(|t| !matches!(t, Type::Var { .. }));
    if let Some(t) = concrete {
        let type_h = ailang_core::canonical::type_hash(t);
        let survivors: BTreeSet<String> = candidates
            .iter()
            .filter(|c| registry.contains_key(&((**c).clone(), type_h.clone())))
            .cloned()
            .collect();
        if survivors.len() == 1 {
            return MethodDispatchOutcome::Resolved(
                survivors.into_iter().next().unwrap(),
            );
        }
        if survivors.len() > 1 {
            let mut sorted: Vec<String> = survivors.into_iter().collect();
            sorted.sort();
            let at_type = format_type_for_display(t);
            return MethodDispatchOutcome::Ambiguous {
                method: method.to_string(),
                at_type,
                candidates: sorted,
            };
        }
        // Zero survivors after type-driven filter: fall through to Multi
        // (the discharge path raises NoInstance with candidates).
    }

    // Constraint-driven filter (rigid-var case or no concrete arg).
    let survivors: BTreeSet<String> = candidates
        .iter()
        .filter(|c| {
            declared_constraints
                .iter()
                .any(|dc| &dc.class == *c)
        })
        .cloned()
        .collect();
    if survivors.len() == 1 {
        return MethodDispatchOutcome::Resolved(
            survivors.into_iter().next().unwrap(),
        );
    }

    // No filter narrowed; emit Multi for discharge-time refinement.
    MethodDispatchOutcome::Multi {
        method: method.to_string(),
        candidates: candidates.clone(),
    }
}

/// mq.2 helper: render a Type as a short surface-shaped string for
/// diagnostic messages.
fn format_type_for_display(t: &Type) -> String {
    match t {
        Type::Con { name, args } if args.is_empty() => name.clone(),
        _ => format!("{:?}", t), // implementer-acceptable fallback
    }
}

Export MethodDispatchOutcome and resolve_method_dispatch as pub (the test crate consumes them via ailang_check::*).

  • Step 4: Run the six tests to confirm GREEN

Run: cargo test --workspace --test method_dispatch_pin

Expected: all six PASS.

  • Step 5: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. New helper exists, not yet called from synth (Task 6).


Task 6: Rewrite synth Var-arm class-method branch per 5-step rule

Files:

  • Modify: crates/ailang-check/src/lib.rs:2028-2056 (synth Var-arm class-method branch)

  • Step 1: Write a regression test (RED-first) that synth produces a single-candidate residual on a unique-method workspace

Append in mod tests in crates/ailang-check/src/lib.rs:

/// mq.2.6: regression — synth's Var-arm class-method branch
/// preserves the pre-mq.2 behaviour on a unique-method workspace
/// (singleton candidate set). The residual carries
/// `candidates: None` and `class` = the qualified class name.
#[test]
fn mq2_synth_var_arm_singleton_preserves_single_class_residual() {
    let mut modules = BTreeMap::new();
    modules.insert(
        "m".to_string(),
        Module {
            name: "m".to_string(),
            imports: vec![],
            defs: vec![
                Def::Class(ClassDef {
                    name: "MyCls".to_string(),
                    param: "a".to_string(),
                    superclass: None,
                    methods: vec![ClassMethod {
                        name: "mymethod".to_string(),
                        ty: Type::Fn {
                            params: vec![Type::Var { name: "a".to_string() }],
                            param_modes: vec![ParamMode::Borrow],
                            ret: Box::new(Type::Con { name: "Unit".to_string(), args: vec![] }),
                            effects: vec![],
                        },
                        default: None,
                    }],
                    doc: None,
                }),
            ],
        },
    );
    let ws = build_workspace_for_test(modules);
    let env = build_check_env(&ws);

    let mut residuals: Vec<ResidualConstraint> = Vec::new();
    let mut counter: u64 = 0;
    let mut locals = BTreeMap::new();
    // synth_term with Term::Var { name: "mymethod" } — exact API may differ;
    // implementer matches the existing in-test synth-invocation pattern.
    let _ty = synth_term_for_test(
        &Term::Var { name: "mymethod".to_string() },
        &env,
        &mut residuals,
        &mut counter,
        &mut locals,
    );

    assert_eq!(residuals.len(), 1);
    assert_eq!(residuals[0].class, "m.MyCls");
    assert_eq!(residuals[0].method, "mymethod");
    assert!(residuals[0].candidates.is_none(),
            "single-candidate path: candidates must be None");
}

(build_workspace_for_test and synth_term_for_test are in-test helpers if they exist; otherwise the implementer inlines the synth-invocation pattern from a neighbouring test.)

  • Step 2: Run test to confirm GREEN (existing behaviour)

Run: cargo test --workspace -p ailang-check mq2_synth_var_arm_singleton_preserves_single_class_residual

Expected: this test should PASS BEFORE the rewrite (existing behaviour already produces single-class residual). The test is a regression guard, not RED-first.

If it fails, the test setup (build_workspace_for_test, synth_term_for_test) is wrong — fix the in-test scaffolding.

  • Step 3: Locate the existing class-method branch

At lib.rs:2028, the existing branch is:

} else if let Some(cm) = env.class_methods.get(name) {
    // Iter 22b.2 (Task 9): instantiate the class param with
    // a fresh metavar; the body's unification at the call
    // site fills it in, and the residual is the class
    // constraint we owe at this use site.
    // ...
    let qualified_method_ty =
        qualify_local_types(&cm.method_ty, &cm.defining_module, &owner_types);
    let fresh = Subst::fresh(counter);
    let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
    mapping.insert(cm.class_param.clone(), fresh.clone());
    let inst_ty = substitute_rigids(&qualified_method_ty, &mapping);
    residuals.push(ResidualConstraint {
        class: cm.class_name.clone(),
        type_: fresh,
        method: name.clone(),
        candidates: None,
    });
    return Ok(inst_ty);
}
  • Step 4: Rewrite the branch per the 5-step rule

Replace the existing else if let Some(cm) = env.class_methods.get(name) branch at lib.rs:2028 with a new branch keyed on method_to_candidate_classes. The new branch handles both the bare-method form (e.g. "show") and the qualified form (e.g. "prelude.Show.show") — parse_method_qualifier extracts the method name and optional class qualifier.

} else if env
    .method_to_candidate_classes
    .contains_key(parse_method_qualifier(name).0)
{
    let (method_name, qualifier_prefix) = parse_method_qualifier(name);
    let candidates = env
        .method_to_candidate_classes
        .get(method_name)
        .expect("contains_key invariant");

    // Synth runs at the Var arm before App-arm unification; concrete
    // arg type is not yet known. The helper resolves on singleton
    // candidates, explicit qualifier, or constraint-driven filter;
    // otherwise emits Multi for discharge-time refinement.
    let registry_unit: BTreeMap<(String, String), ()> = env
        .registry_entries
        .keys()
        .map(|k| (k.clone(), ()))
        .collect();
    let outcome = resolve_method_dispatch(
        method_name,
        qualifier_prefix.as_deref(),
        candidates,
        /*concrete_arg_type*/ None,
        /*declared_constraints*/ &env.active_declared_constraints,
        /*registry*/ &registry_unit,
    );

    // Extract residual shape from outcome; on error variants, return
    // early without touching the residual stack.
    let (residual_class, residual_candidates) = match outcome {
        MethodDispatchOutcome::Resolved(class) => (class, None),
        MethodDispatchOutcome::Multi { candidates, method: _ } => {
            let tentative = candidates
                .iter()
                .next()
                .cloned()
                .unwrap_or_default();
            (tentative, Some(candidates))
        }
        MethodDispatchOutcome::UnknownClass(qname) => {
            return Err(CheckError::UnknownClass { name: qname });
        }
        MethodDispatchOutcome::Ambiguous { method, at_type, candidates } => {
            return Err(CheckError::AmbiguousMethodResolution {
                method,
                at_type,
                candidate_classes: candidates,
            });
        }
    };

    // Look up the ClassMethodEntry by method name (workspace-flat).
    // Today's invariant: any method name in method_to_candidate_classes
    // corresponds to exactly one ClassMethodEntry in env.class_methods.
    let cm = env.class_methods.get(method_name).expect(
        "method_to_candidate_classes invariant: \
         method present implies class_methods entry",
    );
    let owner_types = env
        .module_types
        .get(&cm.defining_module)
        .cloned()
        .unwrap_or_default();
    let qualified_method_ty = qualify_local_types(
        &cm.method_ty,
        &cm.defining_module,
        &owner_types,
    );
    let fresh = Subst::fresh(counter);
    let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
    mapping.insert(cm.class_param.clone(), fresh.clone());
    let inst_ty = substitute_rigids(&qualified_method_ty, &mapping);
    residuals.push(ResidualConstraint {
        class: residual_class,
        type_: fresh,
        method: method_name.to_string(),
        candidates: residual_candidates,
    });
    return Ok(inst_ty);
}

Add the qualifier-parsing helper near the existing qualify_local_types helper (lib.rs scope):

/// mq.2: split a `Term::Var.name` into `(method_name, optional_qualifier_prefix)`
/// at the last dot. `"prelude.Show.show"` → `("show", Some("prelude.Show"))`;
/// `"show"` → `("show", None)`. Always returns a method name (no
/// `Option` outer wrapper); a bare name passes through with `None`
/// qualifier.
fn parse_method_qualifier(name: &str) -> (&str, Option<String>) {
    if let Some(dot_idx) = name.rfind('.') {
        (&name[dot_idx + 1..], Some(name[..dot_idx].to_string()))
    } else {
        (name, None)
    }
}

Note on env.registry_entries and env.active_declared_constraints: the implementer plumbs these accessors through Env. registry_entries is the existing workspace registry (already on Env via the workspace reference); active_declared_constraints is the current fn's Type::Forall.constraints if synth is inside one, else &[] (default-initialized at synth entry).

Implementation notes for the env.active_declared_constraints and env.registry_unit_view references: the implementer plumbs these through Env (or build_check_env's caller). If the registry is already accessible on Env via a different name, use that name; the helper accepts &BTreeMap<(String, String), ()> so the implementer constructs a unit view (.iter().map(|(k, _)| (k.clone(), ()))) once at synth-entry time. active_declared_constraints is the fn's Type::Forall.constraints if synth is inside one, else &[].

  • Step 5: Run the regression test + the six unit tests

Run: cargo test --workspace -p ailang-check mq2_synth_var_arm_singleton mq2_method_dispatch Run: cargo test --workspace --test method_dispatch_pin

Expected: all PASS. The synth Var-arm now goes through the helper for single-class residuals (regression-test PASS) and the six helper unit tests are unaffected.

  • Step 6: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. With MethodNameCollision still in place, the multi-candidate path never fires in real workspaces; the resolver returns Resolved on every singleton candidate set.

  • Step 7: Run full cargo workspace test suite

Run: cargo test --workspace

Expected: green. Downstream consumers (ail CLI, mono path) see the same single-class residuals as pre-rewrite.


Task 7: Constraint-discharge refinement for multi-candidate residuals

Files:

  • Modify: crates/ailang-check/src/lib.rs:1658-1710 (check_fn residual loop body)

  • Step 1: Write a unit test for the multi-candidate concrete-type refinement path

Append in mod tests:

/// mq.2.7: a multi-candidate residual with a concrete `type_`
/// resolved post-unification to `Int` is filtered against the
/// registry. Single survivor → discharged as that class. Multiple
/// survivors → AmbiguousMethodResolution. Zero → NoInstance with
/// candidate-classes populated.
#[test]
fn mq2_discharge_multi_candidate_concrete_type_single_survivor() {
    // Synthetic: candidate set {prelude.Show, userlib.Show}; only
    // prelude.Show has Show Int in the registry. Discharge resolves
    // to prelude.Show.
    let mut candidates = BTreeSet::new();
    candidates.insert("prelude.Show".to_string());
    candidates.insert("userlib.Show".to_string());
    let residual = ResidualConstraint {
        class: "prelude.Show".to_string(), // tentative
        type_: Type::Con { name: "Int".to_string(), args: vec![] },
        method: "show".to_string(),
        candidates: Some(candidates),
    };
    let mut registry: BTreeMap<(String, String), ()> = BTreeMap::new();
    let int_h = ailang_core::canonical::type_hash(
        &Type::Con { name: "Int".to_string(), args: vec![] }
    );
    registry.insert(("prelude.Show".to_string(), int_h), ());

    let outcome = refine_multi_candidate_residual(
        &residual,
        /*declared_constraints*/ &[],
        /*registry*/ &registry,
    );
    assert_eq!(outcome, RefineOutcome::Resolved("prelude.Show".to_string()));
}

#[test]
fn mq2_discharge_multi_candidate_concrete_type_zero_survivors_no_instance() {
    let mut candidates = BTreeSet::new();
    candidates.insert("prelude.Show".to_string());
    candidates.insert("userlib.Show".to_string());
    let residual = ResidualConstraint {
        class: "prelude.Show".to_string(),
        type_: Type::Con { name: "MyType".to_string(), args: vec![] },
        method: "show".to_string(),
        candidates: Some(candidates.clone()),
    };
    let registry: BTreeMap<(String, String), ()> = BTreeMap::new();
    let outcome = refine_multi_candidate_residual(&residual, &[], &registry);
    match outcome {
        RefineOutcome::NoInstance { candidate_classes, .. } => {
            assert_eq!(candidate_classes.len(), 2);
        }
        other => panic!("expected NoInstance, got {other:?}"),
    }
}

#[test]
fn mq2_discharge_multi_candidate_concrete_type_multi_survivors_ambiguous() {
    let mut candidates = BTreeSet::new();
    candidates.insert("prelude.Show".to_string());
    candidates.insert("userlib.Show".to_string());
    let residual = ResidualConstraint {
        class: "prelude.Show".to_string(),
        type_: Type::Con { name: "Int".to_string(), args: vec![] },
        method: "show".to_string(),
        candidates: Some(candidates.clone()),
    };
    let mut registry: BTreeMap<(String, String), ()> = BTreeMap::new();
    let int_h = ailang_core::canonical::type_hash(
        &Type::Con { name: "Int".to_string(), args: vec![] }
    );
    registry.insert(("prelude.Show".to_string(), int_h.clone()), ());
    registry.insert(("userlib.Show".to_string(), int_h), ());
    let outcome = refine_multi_candidate_residual(&residual, &[], &registry);
    match outcome {
        RefineOutcome::Ambiguous { candidate_classes, .. } => {
            assert_eq!(candidate_classes.len(), 2);
        }
        other => panic!("expected Ambiguous, got {other:?}"),
    }
}
  • Step 2: Run tests to confirm RED

Run: cargo test --workspace -p ailang-check mq2_discharge_multi_candidate

Expected: compile error — refine_multi_candidate_residual and RefineOutcome do not exist.

  • Step 3: Add RefineOutcome and refine_multi_candidate_residual

Near the helper from Task 5 in crates/ailang-check/src/lib.rs:

/// mq.2: outcome of multi-candidate residual refinement at discharge
/// time. Consumed by `check_fn`'s residual loop and by mono's
/// residual-to-target mapping.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefineOutcome {
    Resolved(String),
    NoInstance {
        method: String,
        at_type: String,
        candidate_classes: Vec<String>,
    },
    Ambiguous {
        method: String,
        at_type: String,
        candidate_classes: Vec<String>,
    },
    /// Rigid-var residual with multiple constraint-set survivors.
    MissingConstraint {
        method: String,
        candidate_classes: Vec<String>,
    },
}

/// mq.2: refine a multi-candidate residual at discharge time per the
/// spec's 5-step rule (constraint-discharge refinement subsection).
/// Caller is responsible for passing residuals whose `candidates` is
/// `Some(...)`; single-candidate residuals (`candidates: None`) bypass
/// this helper.
pub fn refine_multi_candidate_residual(
    residual: &ResidualConstraint,
    declared_constraints: &[ailang_core::ast::Constraint],
    registry: &BTreeMap<(String, String), ()>,
) -> RefineOutcome {
    let candidates = residual
        .candidates
        .as_ref()
        .expect("refine_multi_candidate_residual called on single-candidate residual");

    // Concrete-type path.
    if !matches!(residual.type_, Type::Var { .. }) {
        let type_h = ailang_core::canonical::type_hash(&residual.type_);
        let survivors: Vec<String> = candidates
            .iter()
            .filter(|c| registry.contains_key(&((**c).clone(), type_h.clone())))
            .cloned()
            .collect();
        let at_type = format_type_for_display(&residual.type_);
        return match survivors.len() {
            0 => RefineOutcome::NoInstance {
                method: residual.method.clone(),
                at_type,
                candidate_classes: candidates.iter().cloned().collect(),
            },
            1 => RefineOutcome::Resolved(survivors.into_iter().next().unwrap()),
            _ => {
                let mut sorted = survivors;
                sorted.sort();
                RefineOutcome::Ambiguous {
                    method: residual.method.clone(),
                    at_type,
                    candidate_classes: sorted,
                }
            }
        };
    }

    // Rigid-var path: filter against declared constraints.
    let survivors: Vec<String> = candidates
        .iter()
        .filter(|c| declared_constraints.iter().any(|dc| &dc.class == *c))
        .cloned()
        .collect();
    match survivors.len() {
        1 => RefineOutcome::Resolved(survivors.into_iter().next().unwrap()),
        _ => RefineOutcome::MissingConstraint {
            method: residual.method.clone(),
            candidate_classes: candidates.iter().cloned().collect(),
        },
    }
}
  • Step 4: Wire the refinement into check_fn's residual-discharge loop

At lib.rs:1658-1710 (per recon, the residual loop body inside check_fn), branch on residual.candidates.is_some() BEFORE the existing single-class registry lookup. For multi-candidate residuals, call refine_multi_candidate_residual, then either continue with the resolved class or return the appropriate CheckError.

Insertion site (just inside the residual for r in residuals loop, before the existing single-class discharge):

if r.candidates.is_some() {
    let registry_unit: BTreeMap<(String, String), ()> = ws
        .registry
        .entries
        .keys()
        .map(|k| (k.clone(), ()))
        .collect();
    match refine_multi_candidate_residual(r, &declared_constraints, &registry_unit) {
        RefineOutcome::Resolved(class) => {
            // Overwrite r.class for the downstream discharge path.
            // Discharge proceeds against (class, type_hash(r.type_)) below.
            let resolved_class = class;
            // ...continue with existing single-class discharge using `resolved_class`...
        }
        RefineOutcome::NoInstance { method, at_type, candidate_classes } => {
            return Err(CheckError::NoInstance {
                class: r.class.clone(), // tentative; candidate_classes is authoritative
                method,
                at_type,
                candidate_classes,
            });
        }
        RefineOutcome::Ambiguous { method, at_type, candidate_classes } => {
            return Err(CheckError::AmbiguousMethodResolution {
                method,
                at_type,
                candidate_classes,
            });
        }
        RefineOutcome::MissingConstraint { method, candidate_classes } => {
            return Err(CheckError::MissingConstraint {
                class: candidate_classes.first().cloned().unwrap_or_default(),
                method,
                at_type: format_type_for_display(&r.type_),
            });
        }
    }
    continue;
}

(The implementer adjusts the snippet to fit the actual control flow of the existing residual loop. The key invariant: multi-candidate residuals never reach the single-class discharge path; they either resolve to a single class or emit a CheckError.)

  • Step 5: Run discharge tests to confirm GREEN

Run: cargo test --workspace -p ailang-check mq2_discharge_multi_candidate

Expected: all three tests PASS.

  • Step 6: Run full workspace test suite

Run: cargo test --workspace

Expected: green. With MethodNameCollision still active, the multi-candidate residual never appears in real workspaces; the new discharge path is exercised only by these unit tests.


Task 8: Mono's residual mapping handles multi-candidate path

Files:

  • Modify: crates/ailang-check/src/mono.rs:1178-1197 (collect_residuals_ordered residual-to-target mapping)

  • Step 1: Write unit tests for mono's residual-class resolver

Append in crates/ailang-check/src/mono.rs mod tests:

/// mq.2.8: mono's residual-class resolver refines multi-candidate
/// residuals via the same logic as discharge. A multi-candidate
/// residual with a concrete `type_` and a single registry-survivor
/// resolves to that class.
#[test]
fn mq2_mono_multi_candidate_resolves_to_single_class() {
    let mut candidates = std::collections::BTreeSet::new();
    candidates.insert("prelude.Show".to_string());
    candidates.insert("userlib.Show".to_string());
    let residual = crate::ResidualConstraint {
        class: "prelude.Show".to_string(), // tentative
        type_: Type::Con { name: "Int".to_string(), args: vec![] },
        method: "show".to_string(),
        candidates: Some(candidates),
    };
    let mut registry_unit: std::collections::BTreeMap<(String, String), ()> = Default::default();
    let int_h = ailang_core::canonical::type_hash(
        &Type::Con { name: "Int".to_string(), args: vec![] }
    );
    registry_unit.insert(("prelude.Show".to_string(), int_h), ());

    let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
    assert_eq!(resolved, Some("prelude.Show".to_string()));
}

/// mq.2.8: single-class residual (candidates: None) flows through
/// unchanged.
#[test]
fn mq2_mono_single_class_residual_unchanged() {
    let residual = crate::ResidualConstraint {
        class: "prelude.Eq".to_string(),
        type_: Type::Con { name: "Int".to_string(), args: vec![] },
        method: "eq".to_string(),
        candidates: None,
    };
    let registry_unit: std::collections::BTreeMap<(String, String), ()> = Default::default();
    let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
    assert_eq!(resolved, Some("prelude.Eq".to_string()));
}

/// mq.2.8: multi-candidate residual that cannot refine (zero
/// registry survivors) returns None.
#[test]
fn mq2_mono_multi_candidate_no_survivors_returns_none() {
    let mut candidates = std::collections::BTreeSet::new();
    candidates.insert("prelude.Show".to_string());
    candidates.insert("userlib.Show".to_string());
    let residual = crate::ResidualConstraint {
        class: "prelude.Show".to_string(),
        type_: Type::Con { name: "MyType".to_string(), args: vec![] },
        method: "show".to_string(),
        candidates: Some(candidates),
    };
    let registry_unit: std::collections::BTreeMap<(String, String), ()> = Default::default();
    let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
    assert_eq!(resolved, None);
}
  • Step 2: Run tests to confirm RED

Run: cargo test --workspace -p ailang-check mq2_mono

Expected: compile error — resolve_residual_class_for_mono does not exist.

  • Step 3: Adjust mono's residual-to-target mapping inline

At mono.rs:1178-1197 (per recon, where residuals get mapped to MonoTarget::ClassMethod), the existing code reads:

let registry_key = (r.class.clone(), ailang_core::canonical::type_hash(&t_ty_norm));
let entry = ws.registry.entries.get(&registry_key);
match entry {
    Some(entry) => MonoTarget::ClassMethod { /* ... */ },
    None => /* None-slot or error */,
}

(Exact shape varies; implementer reads the in-place lines.)

Modify the existing site to refine the residual's class BEFORE constructing the registry key. The refinement uses refine_multi_candidate_residual from Task 7 (importable via crate::refine_multi_candidate_residual), with declared_constraints typically &[] here because mono runs post-typecheck on monomorphic specializations — but the active declared constraints are still plumbed in by collect_residuals_ordered's caller and used for correctness on the rigid-var path (which mono shouldn't normally see in post-check, but the helper handles it for safety).

The inline branch:

// mq.2: refine multi-candidate residuals before mono target lookup.
// Single-class residuals (candidates: None) skip refinement and use
// r.class directly.
let resolved_class = if r.candidates.is_some() {
    let registry_unit: BTreeMap<(String, String), ()> = ws
        .registry
        .entries
        .keys()
        .map(|k| (k.clone(), ()))
        .collect();
    match crate::refine_multi_candidate_residual(
        r,
        /*declared_constraints*/ &[],
        &registry_unit,
    ) {
        crate::RefineOutcome::Resolved(c) => c,
        // Ambiguous / NoInstance / MissingConstraint already fired
        // at typecheck discharge (Task 7); reaching here means
        // mono is processing a residual that check_fn should have
        // rejected. Emit a None-slot defensively — the original
        // CheckError already propagated to the user.
        _ => return None,
    }
} else {
    r.class.clone()
};

let registry_key = (resolved_class.clone(), ailang_core::canonical::type_hash(&t_ty_norm));
let entry = ws.registry.entries.get(&registry_key);
// ...existing match on `entry` continues unchanged, building
//    MonoTarget::ClassMethod { class: resolved_class, ... } in the
//    Some(entry) branch.

This replaces r.class.clone() with resolved_class in the existing registry-key construction and in the MonoTarget::ClassMethod { class: ... } construction. Implementer makes the two-line edit plus the prepended let resolved_class = if r.candidates.is_some() { ... } block.

For the unit test in Step 1 (map_residual_to_mono_target named helper), the test calls into mono directly. Implementer either: (a) extracts the let resolved_class = ... snippet into a small fn resolve_residual_class_for_mono(r, registry_unit) -> Option<String> in mono.rs that the test can call, returning Some(class) for the Resolved case and None for the abort cases; or (b) rewrites the unit test to drive collect_residuals_ordered end-to-end on a synthetic Workspace and assert the resulting MonoTarget. Path (a) is simpler — five-line helper, single test consumer.

If implementer picks path (a), the helper signature is:

/// mq.2: resolve a residual's class for mono target construction.
/// Returns `Some(class)` for a discharge-ready residual (single-class
/// or refined-multi-candidate); `None` if the multi-candidate
/// residual cannot be refined (typecheck-side error already raised).
fn resolve_residual_class_for_mono(
    r: &crate::ResidualConstraint,
    registry_unit: &BTreeMap<(String, String), ()>,
) -> Option<String> {
    if r.candidates.is_some() {
        match crate::refine_multi_candidate_residual(r, &[], registry_unit) {
            crate::RefineOutcome::Resolved(c) => Some(c),
            _ => None,
        }
    } else {
        Some(r.class.clone())
    }
}

And the unit test from Step 1 is rewritten to call resolve_residual_class_for_mono directly. The end-to-end mono behaviour (MonoTarget construction) is then covered by the existing mono test suite (which keeps passing because single-class residuals flow through Some(r.class.clone()) unchanged).

  • Step 4: Run mono multi-candidate test to confirm GREEN

Run: cargo test --workspace -p ailang-check mq2_mono_multi_candidate

Expected: PASS.

  • Step 5: Run full ailang-check test suite

Run: cargo test --workspace -p ailang-check

Expected: green. Pre-mq.2 mono behaviour preserved for single-candidate residuals.


Task 9: Integration verification — full workspace + bench scripts

Files: none modified; verification only.

  • Step 1: Full cargo test --workspace

Run: cargo test --workspace

Expected: green. All ~530 tests across 16 binary-test suites pass.

  • Step 2: Run the six dispatch-pin tests directly

Run: cargo test --workspace --test method_dispatch_pin

Expected: 6 PASS.

  • Step 3: Verify no behavioural change on the prelude / existing fixtures

Run: cargo run -p ail -- check examples/prelude.ail.json Expected: exit 0.

Run: cargo run -p ail -- check examples/mq1_xmod_constraint_class.ail.json Expected: exit 0.

Run: cargo run -p ail -- check examples/eq_ord_polymorphic.ail.json Expected: exit 0.

  • Step 4: Bench scripts

Run: python3 bench/check.py Expected: exit 0 OR audit-ratified — note in the journal whether new variant additions shift any measured metric.

Run: python3 bench/compile_check.py Expected: exit 0 OR audit-ratified.

Run: python3 bench/cross_lang.py Expected: exit 0 OR audit-ratified.

  • Step 5: Roundtrip-check the prelude

Run: git diff -- examples/prelude.ail.json

Expected: no output (mq.2 is pure infrastructure addition; prelude is unchanged).

  • Step 6: Confirm the per-iter journal placeholder is staged

The implementer leaves docs/journals/2026-05-13-iter-mq.2.md (or the date the iter actually finishes) in the working tree with a draft entry covering: tasks ran, deviations from plan (if any), test counts, bench outcomes, and notes on the MethodNameCollision-still-gates-real- workspaces observation (the new dispatch path was exercised exclusively by unit tests in this iter). Append a pointer line to docs/journals/INDEX.md. The Boss commits.