iter mq.tidy: close 4 actionable drift items from audit-mq

T1 [high-1]: refine_multi_candidate_residual rigid-var filter now
requires class + type-unification via constraint_type_matches, so
forall a b. prelude.Show a, userlib.Show b => ... shapes discriminate
by which typevar the residual is on. Discharge-time only; synth-time
twin doesn't have a residual type to unify against (fresh metavar
constructed after dispatch).

T2 [high-2]: qualifier_is_class_shape extracted as pub(crate) helper
adjacent to parse_method_qualifier; broadened to accept PascalCase
single-segment qualifiers like Show.show alongside module-qualified
ones. Same-module bare-class call sites now reachable; symmetric to
mq.1 canonical-form rule.

T3 [medium-1]: any_candidate_class_has_instance pub(crate) helper
gates the class-method-shadowed-by-fn warning closure on registry
instance presence (any candidate class, any type). Conservative
tightening of the spec rule per Boss Q2 decision.

T4 [medium-2]: four DESIGN.md Data Model schema fragments
(SuperclassRef, InstanceDef.class, Type::Con.name, Constraint.class)
annotated with canonical-form cross-references.

Two trip-wires fixed inline: parse_method_qualifier docstring
coherence, mq3 in-crate test fixture-repair (registry instance
injection — analogous to mq.3 typeclass_22b3 pattern).

5/5 tasks. 548 tests green (was 545 + 3 new mq_tidy_* unit tests).
bench/compile_check.py + cross_lang.py exit 0; check.py exit 0
both runs with noise-class metric migration on
latency.implicit_at_rc.* max-tail (6th-consecutive observation
since audit-cma). Baseline pristine.
This commit is contained in:
2026-05-13 02:49:26 +02:00
parent 64d3feeb97
commit 1b6cbcb68b
5 changed files with 484 additions and 18 deletions
+217 -14
View File
@@ -2160,10 +2160,20 @@ pub fn refine_multi_candidate_residual(
};
}
// Rigid-var path: filter against declared constraints.
// Rigid-var path: filter against declared constraints requiring
// BOTH class-name match AND type-unification with the residual's
// `type_` (per spec §"Constraint-discharge refinement" 130-138).
// `constraint_type_matches` is the post-`subst.apply` Var/Con
// recursive matcher; the residual's `type_` has been `subst.apply`-d
// by the discharge caller, so rigid-var identity (Var("a") vs
// Var("b")) is a direct name comparison.
let survivors: Vec<String> = candidates
.iter()
.filter(|c| declared_constraints.iter().any(|dc| &dc.class == *c))
.filter(|c| {
declared_constraints.iter().any(|dc| {
&dc.class == *c && constraint_type_matches(&dc.type_, &residual.type_)
})
})
.cloned()
.collect();
match survivors.len() {
@@ -2225,14 +2235,16 @@ pub(crate) fn qualify_class_ref_in_check(class_ref: &str, caller_module: &str) -
///
/// - `"show"` → `("show", None)`
/// - `"prelude.Show.show"` → `("show", Some("prelude.Show"))`
/// - `"Show.show"` → `("show", Some("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.
/// Callers distinguish class qualifiers from cross-module-fn
/// qualifiers via [`qualifier_is_class_shape`]: PascalCase
/// single-segment prefixes (e.g. `"Show"`) and multi-segment
/// prefixes (e.g. `"prelude.Show"`) are class qualifiers per
/// mq.1's canonical-form rule; lowercase single-segment prefixes
/// (e.g. `"std_list"`) are module names and the caller falls
/// through to the cross-module-fn path.
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()))
@@ -2241,6 +2253,64 @@ pub(crate) fn parse_method_qualifier(name: &str) -> (&str, Option<String>) {
}
}
/// mq.tidy: predicate gating the synth Var-arm's class-method
/// dispatch entry on the qualifier shape. Accepts:
/// - bare-method form (qualifier absent), e.g. `"show"`.
/// - bare-class qualifier, e.g. `"Show.show"` — class names are
/// PascalCase per repo convention (first character uppercase).
/// - module-qualified class qualifier, e.g. `"prelude.Show.show"`.
/// Rejects qualifier shapes that look like cross-module-fn calls
/// (`"std_list.length"`): qualifier starts with lowercase, no
/// inner dot — falls through to the qualified-fn arm.
pub(crate) fn qualifier_is_class_shape(qualifier_opt: &Option<String>) -> bool {
match qualifier_opt {
None => true,
Some(q) => {
// Contains inner dot: module-qualified class
// (`prelude.Show`).
if q.contains('.') {
return true;
}
// Single segment: class iff the first char is uppercase
// (PascalCase convention).
q.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
}
}
}
/// mq.tidy: predicate for the `class-method-shadowed-by-fn`
/// warning emission. The full spec rule (§"Class-fn collisions"
/// 161-162) requires a class candidate WITH AN INSTANCE FOR THE
/// ACTUAL ARG TYPE; the arg type is unavailable at the synth
/// Var-arm where the warning fires (App-arm unification has not
/// run yet). This conservative approximation requires only that
/// at least one candidate class has SOME instance in the
/// workspace registry — stricter than today's "any class
/// declares the method" (which fires even for class methods
/// with zero instances anywhere), looser than the full spec
/// rule. A future tighten can defer the warning emission to a
/// later pass where the arg type is known.
pub(crate) fn any_candidate_class_has_instance(
method_to_candidate_classes: &BTreeMap<String, BTreeSet<String>>,
workspace_registry_entries: &BTreeMap<(String, String), ()>,
method_name: &str,
) -> bool {
let Some(cands) = method_to_candidate_classes.get(method_name) else {
return false;
};
cands.iter().any(|c| {
// BTreeMap is ordered lexicographically on the tuple key;
// `range((c, "")..).next()` is the first entry whose first
// component is >= `c`. The check `k == c` rejects spillover
// into the next class. O(log n) per candidate.
workspace_registry_entries
.range((c.clone(), String::new())..)
.next()
.map(|((k, _), _)| k == c)
.unwrap_or(false)
})
}
/// 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
@@ -2479,6 +2549,26 @@ pub(crate) fn synth(
let emit_shadow_warning_if_class_method =
|name: &str, owner_module: &str, warnings: &mut Vec<crate::diagnostic::Diagnostic>| {
let (method_name, _) = parse_method_qualifier(name);
// mq.tidy: tighten emission per spec §"Class-fn
// collisions" — fire only when at least one
// candidate class has a registry instance (any
// type). Without this, the warning fires on any
// class declaring the method, including class
// methods with zero instances anywhere in the
// workspace.
let registry_unit_view: BTreeMap<(String, String), ()> = env
.workspace_registry
.entries
.keys()
.map(|k| (k.clone(), ()))
.collect();
if !any_candidate_class_has_instance(
&env.method_to_candidate_classes,
&registry_unit_view,
method_name,
) {
return;
}
if let Some(cands) = env.method_to_candidate_classes.get(method_name) {
let mut candidate_list: Vec<String> = cands.iter().cloned().collect();
candidate_list.sort();
@@ -2567,11 +2657,7 @@ pub(crate) fn synth(
// `<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
qualifier_is_class_shape(&qualifier_opt)
&& env.method_to_candidate_classes.contains_key(method_name)
} {
// mq.2: type-driven dispatch entry. The Var arm runs
@@ -6220,11 +6306,30 @@ mod tests {
let mut modules = BTreeMap::new();
modules.insert("clsmod".into(), clsmod);
modules.insert("fnmod".into(), fnmod);
// mq.tidy T3: post-tightening, the warning fires only when at
// least one candidate class has an instance in the workspace
// registry. Ship a `clsmod.Show Int` instance so the fixture
// continues to assert positive-fire behaviour under the
// post-tidy spec rule.
let mut registry = ailang_core::workspace::Registry::default();
let int_h = ailang_core::canonical::type_hash(&Type::int());
registry.entries.insert(
("clsmod.Show".into(), int_h),
ailang_core::workspace::RegistryEntry {
instance: ailang_core::ast::InstanceDef {
class: "clsmod.Show".into(),
type_: Type::int(),
methods: vec![],
doc: None,
},
defining_module: "clsmod".into(),
},
);
let ws = Workspace {
entry: "fnmod".into(),
modules,
root_dir: std::path::PathBuf::from("."),
registry: ailang_core::workspace::Registry::default(),
registry,
};
let mut env = build_check_env(&ws);
// Treat both modules as implicitly imported by `env.imports`
@@ -6302,4 +6407,102 @@ mod tests {
assert!(env.class_methods.contains_key(&key),
"env.class_methods must contain {key:?}");
}
/// mq.tidy T1: discharge-time rigid-var refinement must filter
/// declared constraints by BOTH class-name match AND
/// type-unification with the residual's `type_` (spec
/// §"Constraint-discharge refinement" lines 130-138). Two
/// same-class declared constraints on different typevars must
/// be discriminated by the residual's actual typevar identity.
#[test]
fn mq_tidy_rigid_var_filter_uses_type_unification() {
use ailang_core::ast::Constraint;
// Candidate set: two distinct classes both named "Show".
let mut candidates = BTreeSet::new();
candidates.insert("prelude.Show".to_string());
candidates.insert("userlib.Show".to_string());
// Residual on Var "a".
let residual = ResidualConstraint {
class: "prelude.Show".to_string(),
type_: Type::Var { name: "a".to_string() },
method: "show".to_string(),
candidates: Some(candidates),
};
// Declared: prelude.Show a (matches residual type),
// userlib.Show b (does NOT match residual type).
let declared = vec![
Constraint {
class: "prelude.Show".to_string(),
type_: Type::Var { name: "a".to_string() },
},
Constraint {
class: "userlib.Show".to_string(),
type_: Type::Var { name: "b".to_string() },
},
];
let registry: BTreeMap<(String, String), ()> = BTreeMap::new();
let outcome = refine_multi_candidate_residual(&residual, &declared, &registry);
assert_eq!(
outcome,
RefineOutcome::Resolved("prelude.Show".to_string()),
"type-unification leg must drop userlib.Show because its declared type (Var b) does not match the residual type (Var a)"
);
}
/// mq.tidy T2: the synth Var-arm's class-method dispatch entry
/// gates on a qualifier-shape predicate. The predicate must
/// accept three shapes — bare-method (None), bare-class
/// PascalCase qualifier (`"Show"`), module-qualified class
/// qualifier (`"prelude.Show"`) — and reject bare-fn
/// qualifiers (`"std_list"`, lowercase, no inner dot) which
/// belong to the cross-module-fn arm.
#[test]
fn mq_tidy_bare_class_qualifier_shape_accepted() {
assert!(qualifier_is_class_shape(&None));
assert!(qualifier_is_class_shape(&Some("Show".to_string())));
assert!(qualifier_is_class_shape(&Some("prelude.Show".to_string())));
// Bare fn qualifier (lowercase, no inner dot) — NOT class-shape.
assert!(!qualifier_is_class_shape(&Some("std_list".to_string())));
}
/// mq.tidy T3: the `class-method-shadowed-by-fn` warning fires
/// only when at least one candidate class has a registry
/// instance (any type) per spec §"Class-fn collisions" 161-162.
/// Without this conservative-tightening, the warning fires
/// unconditionally on `method_to_candidate_classes` presence,
/// including class methods with zero instances anywhere.
#[test]
fn mq_tidy_shadow_warning_suppressed_when_no_instance() {
let mut method_to_candidate_classes: BTreeMap<String, BTreeSet<String>> =
BTreeMap::new();
let mut foo_cands = BTreeSet::new();
foo_cands.insert("userlib.Foo".to_string());
method_to_candidate_classes.insert("foo".to_string(), foo_cands);
// Empty registry → no candidate class has an instance → false.
let empty_registry: BTreeMap<(String, String), ()> = BTreeMap::new();
assert!(!any_candidate_class_has_instance(
&method_to_candidate_classes,
&empty_registry,
"foo",
));
// Add one instance for userlib.Foo → true.
let mut registry_with_inst: BTreeMap<(String, String), ()> = BTreeMap::new();
registry_with_inst.insert(
("userlib.Foo".to_string(), "Int_hash".to_string()),
(),
);
assert!(any_candidate_class_has_instance(
&method_to_candidate_classes,
&registry_with_inst,
"foo",
));
}
}