# 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`:

```rust
/// 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:

```rust
/// 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:

```rust
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`:

```rust
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):

```rust
//! - `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`:

```rust
/// 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):

```rust
/// `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:

```rust
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.

```rust
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`:

```rust
/// 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):

```rust
#[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:

```rust
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`:

```rust
/// 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:

```rust
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`:

```rust
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`:

```rust
//! 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:

```rust
/// 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`:

```rust
/// 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:

```rust
} 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.

```rust
} 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):

```rust
/// 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`:

```rust
/// 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`:

```rust
/// 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):

```rust
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`:

```rust
/// 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:

```rust
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:

```rust
// 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:

```rust
/// 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.
