iter ct.1.5a: registry-side normalize-before-hash

This commit is contained in:
2026-05-11 01:32:21 +02:00
parent a73f7d3213
commit 7872ccfc23
3 changed files with 227 additions and 7 deletions
+7 -1
View File
@@ -1538,9 +1538,15 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
// → no-instance. The hash uses `canonical::type_hash`, the
// same key shape `workspace::build_registry` writes with, so
// representation-equal types match deterministically.
// ct.1.5a: route `r_ty` through
// `Registry::normalize_type_for_lookup` so a bare
// `Type::Con` produced for an instance whose head-type lives
// in another module rewrites to the qualified form the
// registry actually keyed on.
let r_ty_norm = env.workspace_registry.normalize_type_for_lookup(&r_ty);
let key = (
r.class.clone(),
ailang_core::canonical::type_hash(&r_ty),
ailang_core::canonical::type_hash(&r_ty_norm),
);
if !env.workspace_registry.entries.contains_key(&key) {
return Err(CheckError::NoInstance {
+19 -3
View File
@@ -115,7 +115,15 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> {
// never mutates the registry.
for t in &new {
let key = mono_target_key(t);
let registry_key = (t.class.clone(), ailang_core::canonical::type_hash(&t.type_));
// ct.1.5a: route `t.type_` through
// `Registry::normalize_type_for_lookup` so a bare
// `Type::Con` produced for an instance whose head-type lives
// in another module rewrites to the qualified form the
// registry actually keyed on. Symmetric to the
// `collect_targets_*` lookups above.
let t_ty_norm = ws_owned.registry.normalize_type_for_lookup(&t.type_);
let registry_key =
(t.class.clone(), ailang_core::canonical::type_hash(&t_ty_norm));
let entry = ws_owned
.registry
.entries
@@ -513,7 +521,11 @@ pub fn collect_mono_targets(
if !crate::is_fully_concrete(&r_ty) {
continue;
}
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty));
// ct.1.5a: normalise `r_ty` to its always-qualified canonical
// form before hashing — symmetric to the write side in
// `workspace::build_registry`.
let r_ty_norm = env.workspace_registry.normalize_type_for_lookup(&r_ty);
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty_norm));
let entry = match env.workspace_registry.entries.get(&key) {
Some(e) => e,
None => continue, // no-instance — Task 10 of 22b.2 fires; skip silently here.
@@ -867,7 +879,11 @@ pub(crate) fn collect_residuals_ordered(
out.push(None);
continue;
}
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty));
// ct.1.5a: normalise `r_ty` to its always-qualified canonical
// form before hashing — symmetric to the write side in
// `workspace::build_registry`.
let r_ty_norm = env.workspace_registry.normalize_type_for_lookup(&r_ty);
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty_norm));
let entry = match env.workspace_registry.entries.get(&key) {
Some(e) => e,
None => {
+201 -3
View File
@@ -86,6 +86,30 @@ pub struct Workspace {
pub struct Registry {
/// Map from `(class-name, type-hash)` to the registry entry.
pub entries: BTreeMap<(String, String), RegistryEntry>,
/// ct.1.5a: workspace-wide map from user-defined type-name to its
/// defining module. Used by [`Self::normalize_type_for_lookup`] to
/// rewrite a bare `Type::Con.name` to its always-qualified form
/// before computing the registry key. Primitives are not present.
/// Populated in [`build_registry`] from the same scan that builds
/// the entry map.
pub type_def_module: BTreeMap<String, String>,
}
impl Registry {
/// ct.1.5a: produce the canonical form of `t` for registry-key
/// hashing. Bare-non-primitive `Type::Con` names get qualified to
/// `<defining_module>.<name>`; already-qualified names stay; bare
/// names whose defining module is unknown stay as-is.
/// `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass through
/// structurally.
///
/// Every consumer that hashes an `inst.type_`-shaped expression to
/// look it up in [`Self::entries`] must funnel through this
/// helper, otherwise the registered-form and the queried-form
/// disagree on whether the leading qualifier is present.
pub fn normalize_type_for_lookup(&self, t: &Type) -> Type {
normalize_type_for_registry(t, &self.type_def_module)
}
}
/// One entry in the [`Registry`].
@@ -591,8 +615,15 @@ fn build_registry(
}
// Uniqueness: a `(class, type-hash)` key must appear
// at most once across the whole workspace.
let type_hash = canonical::type_hash(&inst.type_);
// at most once across the whole workspace. ct.1.5a: the
// type expression is normalised to its always-qualified
// form before hashing so a bare-local declaration in
// the type's defining module and a qualified-cross-module
// declaration from elsewhere produce the same key (both
// refer to the same type under the canonical-form rule).
let type_hash = canonical::type_hash(
&normalize_type_for_registry(&inst.type_, &type_def_module),
);
let key = (inst.class.clone(), type_hash);
if let Some(prior) = entries.get(&key) {
return Err(WorkspaceLoadError::DuplicateInstance {
@@ -684,7 +715,10 @@ fn build_registry(
}
}
Ok(Registry { entries })
Ok(Registry {
entries,
type_def_module,
})
}
/// Iter 22b.2: class-schema validation. Runs before `build_registry`.
@@ -773,6 +807,70 @@ fn is_primitive_type_name(name: &str) -> bool {
matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float")
}
/// ct.1.5a: normalize a `Type` by qualifying every bare-non-primitive
/// `Type::Con.name` to its always-qualified form
/// (`<defining_module>.<name>`). Primitives stay bare; already-qualified
/// names stay as-is; bare names whose defining module is unknown stay
/// as-is (downstream diagnostics will catch them). `Type::Fn` /
/// `Type::Forall` / `Type::Var` recurse / pass through structurally.
///
/// Used by [`build_registry`] for registry-key canonicalisation so the
/// duplicate-instance check survives the asymmetric "bare = local,
/// qualified = cross-module" canonical-form rule: an instance declared
/// bare-local in the type's defining module and an equivalent one
/// declared qualified-cross-module from elsewhere produce the same
/// registry key.
///
/// The qualifier is the type's *defining* module (looked up in
/// `type_def_module`), not the instance's owning module. The two
/// coincide under a canonical-form-compliant workspace (bare implies
/// local-to-defining-module), but using the defining-module lookup is
/// robust against pre-`validate_canonical_type_names`-wired fixtures
/// that may still carry bare cross-module refs.
fn normalize_type_for_registry(
t: &Type,
type_def_module: &BTreeMap<String, String>,
) -> Type {
match t {
Type::Con { name, args } => {
let new_name = if name.contains('.') || is_primitive_type_name(name) {
name.clone()
} else if let Some(owner) = type_def_module.get(name) {
format!("{owner}.{name}")
} else {
// Unknown bare non-primitive — leave as-is. Either it is
// a class-param Type::Var miscoded as a Con (which is a
// separate well-formedness problem) or a stale ref that
// downstream diagnostics will catch.
name.clone()
};
Type::Con {
name: new_name,
args: args
.iter()
.map(|a| normalize_type_for_registry(a, type_def_module))
.collect(),
}
}
Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn {
params: params
.iter()
.map(|p| normalize_type_for_registry(p, type_def_module))
.collect(),
param_modes: param_modes.clone(),
ret: Box::new(normalize_type_for_registry(ret, type_def_module)),
ret_mode: *ret_mode,
effects: effects.clone(),
},
Type::Forall { vars, constraints, body } => Type::Forall {
vars: vars.clone(),
constraints: constraints.clone(),
body: Box::new(normalize_type_for_registry(body, type_def_module)),
},
Type::Var { name } => Type::Var { name: name.clone() },
}
}
/// ct.1: enforce the canonical-form rule on every `Type::Con` and
/// `Term::Ctor.type_name` reference in every loaded module. Runs
/// after prelude injection and before class-schema validation, so a
@@ -2232,6 +2330,106 @@ mod tests {
}
}
/// ct.1.5a: registry-side duplicate detection survives the
/// asymmetric canonical-form representation. Two coherent instances
/// on the same type-def, one declared bare-local (in the type's
/// defining module) and one declared qualified-cross-module (in the
/// class's defining module), must collide on the registry's
/// `(class, type-hash)` key.
///
/// This is the regression that broke during the ct.1.5 migration
/// dry-run: after migrating `test_22b1_dup_a.ail.json` to qualified
/// `test_22b1_dup_b.MyInt`, the unmigrated `test_22b1_dup_b.ail.json`
/// (which keeps bare `MyInt` because the type IS local there)
/// produced a different type-hash, and the duplicate detection
/// silently missed.
///
/// The fixture is constructed in-memory rather than from disk so
/// the test stays focused on the registry behaviour rather than the
/// migration tool. Class `TShow` is named distinctly from the
/// prelude's `Eq`/`Show` to avoid collisions with the auto-loaded
/// prelude — but `build_registry` here is called directly on a flat
/// `BTreeMap` (no auto-prelude injection), so the choice is purely
/// belt-and-braces.
#[test]
fn ct1_registry_duplicate_detection_survives_mixed_canonical_form() {
// Module `cls`: declares `class TShow a` (custom name to avoid
// any future prelude collision) and the qualified-cross-module
// instance `instance TShow other.MyInt`.
let cls: Module = serde_json::from_value(serde_json::json!({
"schema": crate::SCHEMA,
"name": "cls",
"imports": [{ "module": "other" }],
"defs": [
{
"kind": "class",
"name": "TShow",
"param": "a",
"methods": [
{ "name": "tshow",
"type": { "k": "fn",
"params": [{ "k": "var", "name": "a" }],
"ret": { "k": "con", "name": "Str" },
"effects": [] } }
]
},
{
"kind": "instance",
"class": "TShow",
"type": { "k": "con", "name": "other.MyInt" },
"methods": [
{ "name": "tshow",
"body": { "t": "lit", "lit": { "kind": "str", "value": "cls-side" } } }
]
}
],
})).unwrap();
// Module `other`: declares `type MyInt` and the bare-local
// instance `instance TShow MyInt`. Imports `cls` for shape
// coherence (the dependency direction needed to bring `class
// TShow` into scope under the actual loader).
let other: Module = serde_json::from_value(serde_json::json!({
"schema": crate::SCHEMA,
"name": "other",
"imports": [{ "module": "cls" }],
"defs": [
{ "kind": "type", "name": "MyInt", "ctors": [{ "name": "MkMyInt", "fields": [] }] },
{
"kind": "instance",
"class": "TShow",
"type": { "k": "con", "name": "MyInt" },
"methods": [
{ "name": "tshow",
"body": { "t": "lit", "lit": { "kind": "str", "value": "other-side" } } }
]
}
],
})).unwrap();
let mut modules = BTreeMap::new();
modules.insert("cls".to_string(), cls);
modules.insert("other".to_string(), other);
let err = build_registry(&modules).expect_err(
"registry build must fire DuplicateInstance for mixed canonical-form"
);
match err {
WorkspaceLoadError::DuplicateInstance {
class, first_module, second_module, ..
} => {
assert_eq!(class, "TShow");
let mods: BTreeSet<&str> =
[first_module.as_str(), second_module.as_str()].into_iter().collect();
assert!(mods.contains("cls"),
"got {first_module}, {second_module}");
assert!(mods.contains("other"),
"got {first_module}, {second_module}");
}
other => panic!("expected DuplicateInstance, got {other:?}"),
}
}
/// ct.1: a qualified `Constraint.class` nested inside a
/// `ClassDef.methods[].ty.Forall.constraints` must fire
/// `QualifiedClassName`. Distinct from the `Def::Fn` site: the