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.
60 KiB
Iter mq.2 — Type-driven dispatch mechanism (installed, not yet exercised) — Implementation Plan
Parent spec:
docs/specs/0023-module-qualified-class-names.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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
ResidualConstraintwithcandidates: Option<BTreeSet<String>>(None = single-class today path; Some = multi-candidate post-mq.3 path). method_to_candidate_classeslives onEnv(workspace-flat). Per- module form skipped because candidate sets span the workspace by definition.NoInstance.candidate_classeswired throughto_diagnosticin this iter (the field is added with rendering at the same time).qualify_class_ref_in_checkconsolidation deferred per mq.1 known-debt entry; not in scope.- Mq.1 invariant: synth Var-arm at
lib.rs:2028already consumes qualifiedcm.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.rsmod 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.rsmodule 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.rsmod 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.rsmod 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
ResidualConstraintconstruction sites to passcandidates: 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.rsmod 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 nearResidualConstraint) -
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*/ ®istry,
);
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),
&[],
®istry,
);
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),
&[],
®istry,
);
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),
&[],
®istry,
);
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,
®istry,
);
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),
&[],
®istry,
);
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
MethodDispatchOutcometype 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*/ ®istry_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*/ ®istry,
);
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, &[], ®istry);
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, &[], ®istry);
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
RefineOutcomeandrefine_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, ®istry_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, ®istry_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, ®istry_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, ®istry_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(®istry_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*/ &[],
®istry_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(®istry_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.