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

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

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

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

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

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

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

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

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

9/9 tasks, 539 tests green (was 520 pre-mq.2; +15 mq.2 unit tests +
4 pre-existing). bench/compile_check.py + cross_lang.py clean;
bench/check.py 1 regression (latency noise — runtime cannot be
touched by a typecheck-side iter).
This commit is contained in:
2026-05-13 01:40:42 +02:00
parent e9e45c77af
commit 2e6a4ca200
7 changed files with 1203 additions and 19 deletions
+17 -1
View File
@@ -75,7 +75,23 @@
//! Dual of `missing-constraint`: when the residual is concrete, the
//! fn cannot push the obligation to a caller, so an existing
//! instance is the only way to discharge it. `ctx`:
//! `{"class": "<C>", "method": "<m>", "at_type": "<T>"}`.
//! `{"class": "<C>", "method": "<m>", "at_type": "<T>"}`. mq.2 adds
//! an optional `candidate_classes` field when the residual originated
//! from the multi-candidate dispatch path; absent on single-class
//! residuals (back-compat).
//! - `ambiguous-method-resolution` (mq.2) — `severity: error`. Emitted
//! by the type-driven dispatch resolver (or the discharge-time
//! multi-candidate refinement) when a bare-method call site survives
//! both type-driven and constraint-driven filtering with more than
//! one candidate class. `ctx`: `{"method": "<m>", "at_type": "<T>",
//! "candidate_classes": ["<C1>", ...]}`. The author disambiguates
//! by writing `<ClassQualifier>.<method> x`.
//! - `unknown-class` (mq.2) — `severity: error`. Emitted by the
//! type-driven dispatch resolver when an explicit class qualifier
//! in a `Term::Var.name` (e.g. `"prelude.Show.show"`) names a
//! qualified class that is not in the workspace registry of
//! candidate classes for the method. `ctx`:
//! `{"name": "<qualified-class>"}`.
use serde::Serialize;
+730 -16
View File
@@ -569,6 +569,38 @@ pub enum CheckError {
method: String,
class: String,
at_type: String,
/// mq.2: additive candidate-class list. Empty on residuals from
/// the single-class dispatch path (pre-mq.2 shape preserved).
/// Non-empty when the diagnostic originated from the
/// multi-candidate refinement path with zero registry survivors.
candidate_classes: Vec<String>,
},
/// 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.
/// Code: `ambiguous-method-resolution`. `ctx`:
/// `{"method": "<m>", "at_type": "<T>", "candidate_classes": ["<C1>", ...]}`.
#[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 is not in the workspace registry of
/// candidate classes for the method. Code: `unknown-class`. `ctx`:
/// `{"name": "<qualified-class>"}`.
#[error("class `{name}` is not declared in any module of this workspace")]
UnknownClass {
name: String,
},
/// Iter 22b.3: an internal invariant in the typechecker / mono pass
@@ -617,6 +649,8 @@ impl CheckError {
CheckError::ReuseAsNonAllocatingBody { .. } => "reuse-as-non-allocating-body",
CheckError::MissingConstraint { .. } => "missing-constraint",
CheckError::NoInstance { .. } => "no-instance",
CheckError::AmbiguousMethodResolution { .. } => "ambiguous-method-resolution",
CheckError::UnknownClass { .. } => "unknown-class",
CheckError::Internal(_) => "internal",
}
}
@@ -658,8 +692,26 @@ impl CheckError {
CheckError::MissingConstraint { class, method, at_type, .. } => {
serde_json::json!({"class": class, "method": method, "at_type": at_type})
}
CheckError::NoInstance { class, method, at_type, .. } => {
serde_json::json!({"class": class, "method": method, "at_type": at_type})
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
}
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 })
}
_ => serde_json::Value::Object(serde_json::Map::new()),
}
@@ -1291,6 +1343,14 @@ pub fn build_check_env(ws: &Workspace) -> Env {
for g in mg.values() {
for (n, e) in &g.class_methods {
env.class_methods.insert(n.clone(), e.clone());
// mq.2: build the inverse method → candidate-class set in
// the same loop. `e.class_name` already carries the
// qualified form (mq.1 invariant), so the set is keyed on
// workspace-flat qualified class names directly.
env.method_to_candidate_classes
.entry(n.clone())
.or_default()
.insert(e.class_name.clone());
}
}
@@ -1657,6 +1717,68 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
let expanded = expand_declared_constraints(&declared_constraints, &env.class_superclasses);
for r in &residuals {
let r_ty = subst.apply(&r.type_);
// mq.2: multi-candidate residuals refine before the existing
// single-class discharge path. `MethodNameCollision` keeps the
// candidate set singleton in real workspaces today; this branch
// is exercised exclusively by unit tests until mq.3.
if r.candidates.is_some() {
// Normalize the type before hashing — same contract as the
// single-class path below.
let r_ty_norm = env
.workspace_registry
.normalize_type_for_lookup(env.current_module.as_str(), &r_ty);
let normalized_residual = ResidualConstraint {
class: r.class.clone(),
type_: r_ty_norm,
method: r.method.clone(),
candidates: r.candidates.clone(),
};
let registry_unit: BTreeMap<(String, String), ()> = env
.workspace_registry
.entries
.keys()
.map(|k| (k.clone(), ()))
.collect();
match refine_multi_candidate_residual(
&normalized_residual,
&expanded,
&registry_unit,
) {
RefineOutcome::Resolved(_) => {
// Discharge succeeded — the resolved class has an
// instance for this concrete type (or the rigid
// var has a matching declared constraint). Move
// on to the next residual; nothing to push.
continue;
}
RefineOutcome::NoInstance { method, at_type, candidate_classes } => {
return Err(CheckError::NoInstance {
def: f.name.clone(),
method,
class: r.class.clone(),
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 {
def: f.name.clone(),
method,
class: candidate_classes.first().cloned().unwrap_or_default(),
at_type: ailang_core::pretty::type_to_string(&r_ty),
});
}
}
}
if let Type::Var { name } = &r_ty {
// Skip residuals over metavars (`$mN`) — those mean the
// residual is over a value the body never connected to a
@@ -1699,6 +1821,7 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
method: r.method.clone(),
class: r.class.clone(),
at_type: ailang_core::pretty::type_to_string(&r_ty),
candidate_classes: vec![],
});
}
}
@@ -1735,8 +1858,14 @@ pub(crate) fn is_fully_concrete(t: &Type) -> bool {
/// argument's actual type) or, after `subst.apply`, a rigid var or a
/// concrete `Type::Con`.
#[derive(Debug, Clone)]
pub(crate) struct ResidualConstraint {
pub struct ResidualConstraint {
/// Class name (e.g. `Show`).
///
/// When [`Self::candidates`] is `None`, this is the resolved class
/// (single-class dispatch path). When `Some(set)`, this is a
/// tentative value (typically the first set element in BTreeSet
/// order); discharge / mono refinement overwrites it with the
/// actually-resolved class.
pub class: String,
/// Class param's instance at this call site. Pre-substitution: the
/// fresh metavar inserted at the Var site. Post-substitution: the
@@ -1746,6 +1875,243 @@ pub(crate) struct ResidualConstraint {
/// diagnostic to point the author at which call site triggered
/// the residual.
pub method: String,
/// mq.2: candidate-class set for the multi-candidate dispatch
/// path. `None` ⇒ single-class semantics (pre-mq.2 behaviour);
/// the [`Self::class`] field is the resolved class. `Some(set)`
/// ⇒ multi-candidate residual; discharge / mono refinement filters
/// the set at fn-body-end. With `MethodNameCollision` still active
/// (pre-mq.3), real workspaces only ever construct `None`-candidate
/// residuals; the `Some`-path is exercised exclusively by unit
/// tests until mq.3 retires the workaround.
pub candidates: Option<BTreeSet<String>>,
}
/// mq.2: outcome of the dispatch-resolution helper
/// [`resolve_method_dispatch`]. 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 the caller has the arg type
/// available (e.g. discharge-time refinement); `None` when synth is at
/// the Var arm before App-arm unification. When `None`, the helper
/// emits `Multi` if the candidate set is non-singleton (no early
/// type-driven filter possible). When `Some(t)` with `t` a non-rigid
/// concrete type, the type-driven filter applies; when `t` is a rigid
/// `Type::Var`, the constraint-driven filter applies.
///
/// `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. Values are unit; only
/// key presence matters.
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, singleton candidate set — pre-mq.3
// path. Returns immediately so the post-mq.3 multi-candidate
// logic below stays gated on a non-singleton candidate set.
if candidates.len() == 1 {
return MethodDispatchOutcome::Resolved(
candidates.iter().next().cloned().unwrap(),
);
}
// Multi-candidate path: type-driven filter first when the 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 = ailang_core::pretty::type_to_string(t);
return MethodDispatchOutcome::Ambiguous {
method: method.to_string(),
at_type,
candidates: sorted,
};
}
// Zero survivors after the type-driven filter: fall through
// to constraint-driven filter, then `Multi` for discharge-
// time refinement (where `NoInstance` with `candidate_classes`
// is fired against the full candidate set).
}
// 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: 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 {
/// Refinement converged on a single class.
Resolved(String),
/// Concrete `type_` with zero registry survivors — the call site
/// must be discharged via an existing instance, but none of the
/// candidate classes has one for this type. Surfaces as a
/// `CheckError::NoInstance` with the full candidate set populated.
NoInstance {
method: String,
at_type: String,
candidate_classes: Vec<String>,
},
/// Concrete `type_` with multiple registry survivors — every
/// surviving candidate has an instance for this type; the author
/// must disambiguate explicitly. Surfaces as a
/// `CheckError::AmbiguousMethodResolution`.
Ambiguous {
method: String,
at_type: String,
candidate_classes: Vec<String>,
},
/// Rigid-var residual with non-singleton constraint-set survivors.
/// Surfaces as a `CheckError::MissingConstraint` (the body must
/// declare a constraint to commit to one of the candidates).
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 MUST pass a residual whose `candidates` is `Some(...)`;
/// single-candidate residuals (`candidates: None`) bypass this helper
/// and discharge directly against the registry as in pre-mq.2.
///
/// Refinement strategy:
/// - Concrete `type_`: filter the candidate set against the registry
/// keyed on `(class, type_hash(type_))`. 0 survivors → `NoInstance`;
/// 1 → `Resolved`; >1 → `Ambiguous`.
/// - Rigid-var `type_`: filter the candidate set against the active
/// declared-constraint list. 1 survivor → `Resolved`; otherwise
/// → `MissingConstraint`.
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 = ailang_core::pretty::type_to_string(&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(),
},
}
}
/// Iter 23.4: per-call-site polymorphic-free-fn observation recorded by
@@ -1793,6 +2159,27 @@ pub(crate) fn qualify_class_ref_in_check(class_ref: &str, caller_module: &str) -
}
}
/// mq.2: split a `Term::Var.name` into `(method_name,
/// optional_qualifier_prefix)` at the last dot.
///
/// - `"show"` → `("show", None)`
/// - `"prelude.Show.show"` → `("show", Some("prelude.Show"))`
/// - `"std_list.length"` → `("length", Some("std_list"))`
///
/// Callers gate on the qualifier shape: a class qualifier is always
/// qualified per mq.1's canonical-form rule, so a single-segment
/// prefix (no dot) is structurally a module name and the caller falls
/// through to the cross-module-fn path. A multi-segment prefix
/// (`<module>.<Class>`) signals an explicit class qualifier on a
/// class-method call site.
pub(crate) 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)
}
}
/// Iter 22b.2 (Task 9): expand a list of declared class constraints
/// with their one-step superclass closure (Decision 11). For every
/// declared `(C, t)`, append `(S, t)` if class `C` has a superclass
@@ -2025,18 +2412,92 @@ pub(crate) fn synth(
.filter(|m| m.contains_key(name))
.map(|_| (env.current_module.clone(), name.clone()));
(t.clone(), owner)
} 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.
} else if {
// mq.2 dispatch entry: the Var arm matches a class
// method either bare (`"show"`) or qualified
// (`"prelude.Show.show"`). `parse_method_qualifier`
// splits at the last dot; a `Some(qualifier)` with no
// inner dot is a 1-dot name (`std_list.length`) —
// structurally a cross-module-fn call per mq.1's
// canonical-form rule (class qualifiers are always
// `<module>.<Class>`), so we fall through to the
// qualified-fn arm below in that case.
let (method_name, qualifier_opt) = parse_method_qualifier(name);
let qualifier_is_class_shape = match &qualifier_opt {
None => true,
Some(q) => q.contains('.'),
};
qualifier_is_class_shape
&& env.method_to_candidate_classes.contains_key(method_name)
} {
// mq.2: type-driven dispatch entry. The Var arm runs
// before App-arm unification, so the arg type is not
// available here. The dispatch helper resolves on
// (a) explicit class qualifier, (b) singleton candidate
// set (today's invariant), or (c) constraint-driven
// filter (rigid-var case); otherwise it emits `Multi`
// for discharge-time refinement.
//
// ct.2 Task 1: a class method's declared type lives in
// its defining module's bare-local namespace.
// qualify_local_types runs symmetrically with the
// Term::Var qualified path below so the consumer's
// typecheck context sees the method's return /
// parameter Type::Cons fully qualified.
// With `MethodNameCollision` still active (pre-mq.3),
// real workspaces never reach the `Multi` branch — the
// candidate set is always singleton. The branch is
// installed here for mq.3 to exercise without further
// dispatch-side edits.
let (method_name, qualifier_opt) = parse_method_qualifier(name);
let candidates = env
.method_to_candidate_classes
.get(method_name)
.expect("contains_key invariant: gate above guarantees presence");
// Build a unit-view of the workspace registry keyed by
// (qualified_class, type_hash). Values are unit; only
// key presence matters for the type-driven filter.
let registry_unit: BTreeMap<(String, String), ()> = env
.workspace_registry
.entries
.keys()
.map(|k| (k.clone(), ()))
.collect();
let outcome = resolve_method_dispatch(
method_name,
qualifier_opt.as_deref(),
candidates,
/*concrete_arg_type*/ None,
/*declared_constraints*/ &[],
/*registry*/ &registry_unit,
);
let (residual_class, residual_candidates) = match outcome {
MethodDispatchOutcome::Resolved(class) => (class, None),
MethodDispatchOutcome::Multi { candidates: cs, method: _ } => {
// Tentative class: first BTreeSet element.
// Refinement at discharge / mono time overwrites it.
let tentative = cs.iter().next().cloned().unwrap_or_default();
(tentative, Some(cs))
}
MethodDispatchOutcome::UnknownClass(qname) => {
return Err(CheckError::UnknownClass { name: qname });
}
MethodDispatchOutcome::Ambiguous { method, at_type, candidates: cs } => {
return Err(CheckError::AmbiguousMethodResolution {
method,
at_type,
candidate_classes: cs,
});
}
};
// Look up the `ClassMethodEntry` by method name —
// workspace-flat. The `MethodNameCollision` invariant
// (pre-mq.3) guarantees the lookup is unambiguous. Post-
// mq.3, the class_methods table is keyed by
// `(class, method)` and this `.get(method_name)` will
// need to be reworked alongside the table.
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)
@@ -2049,9 +2510,10 @@ pub(crate) fn synth(
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(),
class: residual_class,
type_: fresh,
method: name.clone(),
method: method_name.to_string(),
candidates: residual_candidates,
});
return Ok(inst_ty);
} else if let Some((owner_module, raw_ty)) = env
@@ -2902,6 +3364,13 @@ pub struct Env {
/// method-name-collision check (Iter 22b.2.6), so the merge is
/// safe (no overwrites). Read in [`synth`]'s `Term::Var` arm.
pub class_methods: BTreeMap<String, ClassMethodEntry>,
/// mq.2: workspace-flat inverse of [`Self::class_methods`] — for
/// each method name, the set of qualified class names that declare
/// it. Pre-mq.3, the `MethodNameCollision` invariant guarantees
/// each set is singleton; mq.3 lifts the invariant and cardinality
/// > 1 becomes legal. Synth's `Term::Var` arm consults this index
/// for type-driven dispatch (spec §Architecture's 5-step rule).
pub method_to_candidate_classes: BTreeMap<String, BTreeSet<String>>,
/// Iter 22b.2 (Task 9): one-step superclass expansion table.
/// Maps each class name to its superclass class name. Absence from
/// the map means the class has no superclass — populated only for
@@ -5302,4 +5771,249 @@ mod tests {
"expected bare `dbl` to resolve through implicit prelude import; got {diags:?}"
);
}
/// 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}");
}
/// mq.2.2: `NoInstance` carries an additive `candidate_classes`
/// list. When non-empty, the rendered ctx() carries the candidate
/// list; when empty, the ctx() shape 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 {
def: "f".to_string(),
class: "prelude.Show".to_string(),
method: "show".to_string(),
at_type: "MyType".to_string(),
candidate_classes: vec![
"prelude.Show".to_string(),
"userlib.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}");
let ctx = err.ctx();
assert_eq!(ctx["candidate_classes"][0], "prelude.Show");
assert_eq!(ctx["candidate_classes"][1], "userlib.Show");
}
/// mq.2.2: empty `candidate_classes` keeps the existing single-class
/// ctx() shape — no `candidate_classes` key in the JSON.
#[test]
fn mq2_no_instance_empty_candidates_back_compat() {
let err = CheckError::NoInstance {
def: "f".to_string(),
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}");
let ctx = err.ctx();
assert!(ctx.get("candidate_classes").is_none(),
"empty candidate_classes must not render in ctx; got: {ctx}");
}
/// 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 a tentative value until discharge
/// resolves it.
#[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));
}
/// mq.2.5: smoke test on `parse_method_qualifier` — the helper
/// underpinning the rewritten synth Var-arm. Bare names pass
/// through with `None` qualifier; dotted names split at the last
/// dot so the prefix is the (possibly-multi-dot) class qualifier.
#[test]
fn mq2_parse_method_qualifier_smoke() {
assert_eq!(parse_method_qualifier("show"), ("show", None));
assert_eq!(
parse_method_qualifier("prelude.Show.show"),
("show", Some("prelude.Show".to_string()))
);
assert_eq!(
parse_method_qualifier("std_list.length"),
("length", Some("std_list".to_string()))
);
}
/// mq.2.7: a multi-candidate residual with a concrete `type_`
/// resolved post-unification to `Int` is filtered against the
/// registry. Single survivor → Resolved.
#[test]
fn mq2_discharge_multi_candidate_concrete_type_single_survivor() {
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()));
}
/// mq.2.7: zero registry survivors → NoInstance with the full
/// candidate-class set echoed back to the author.
#[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:?}"),
}
}
/// mq.2.7: multi survivors → AmbiguousMethodResolution.
#[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:?}"),
}
}
/// 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() {
use ailang_core::ast::{ClassDef, ClassMethod, Module};
let m = Module {
schema: SCHEMA.into(),
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![],
ret_mode: ParamMode::Implicit,
},
default: None,
}],
doc: None,
})],
};
let mut modules = BTreeMap::new();
modules.insert("m".into(), m);
let ws = Workspace {
entry: "m".into(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
};
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");
}
}
+110 -2
View File
@@ -1108,6 +1108,27 @@ fn pattern_binders(pat: &Pattern) -> Vec<String> {
out
}
/// 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 — in which case typecheck-side already raised a
/// `CheckError`, so reaching this branch means the residual slipped
/// past discharge and the mono cursor should emit a None-slot
/// defensively.
pub 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())
}
}
/// Iter 22b.3: traversal-ordered residual collection — used by
/// the rewrite walker to align cursor positions. Unlike
/// [`collect_mono_targets`], this includes non-concrete residuals
@@ -1185,10 +1206,33 @@ pub(crate) fn collect_residuals_ordered(
let r_ty_norm = env
.workspace_registry
.normalize_type_for_lookup(module_name, &r_ty);
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty_norm));
// mq.2: resolve the residual's class via the multi-
// candidate refinement helper. Single-class residuals
// (`candidates: None`) flow through `r.class.clone()`
// unchanged; multi-candidate residuals filter against
// the registry, with the discharge path's CheckError
// already raised at typecheck time (so reaching here
// with a non-Resolved outcome means the residual was
// somehow not gated upstream — emit `None` defensively).
let registry_unit: BTreeMap<(String, String), ()> = env
.workspace_registry
.entries
.keys()
.map(|k| (k.clone(), ()))
.collect();
let normalized_residual = crate::ResidualConstraint {
class: r.class.clone(),
type_: r_ty_norm.clone(),
method: r.method.clone(),
candidates: r.candidates.clone(),
};
let resolved_class =
resolve_residual_class_for_mono(&normalized_residual, &registry_unit)?;
let key = (resolved_class.clone(), ailang_core::canonical::type_hash(&r_ty_norm));
let entry = env.workspace_registry.entries.get(&key)?;
Some(MonoTarget::ClassMethod {
class: r.class.clone(),
class: resolved_class,
method: r.method.clone(),
type_: r_ty,
defining_module: entry.defining_module.clone(),
@@ -1370,3 +1414,67 @@ fn interleave_slots(
Term::Lit { .. } => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ailang_core::ast::Type;
/// 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: 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: 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: BTreeMap<(String, String), ()> = Default::default();
let resolved = resolve_residual_class_for_mono(&residual, &registry_unit);
assert_eq!(resolved, None);
}
}
@@ -0,0 +1,154 @@
//! 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_core::ast::{Constraint, 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)
// 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() };
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()],
},
);
}