iter 22b.1.2: workspace registry skeleton + coherence checks

Adds the workspace-global typeclass instance registry per Decision 11
"Resolution and monomorphisation". Built at the end of load_workspace
after the DFS over imports completes; keyed by (class-name, type-hash)
where type-hash uses the new canonical::type_hash (16-hex prefix,
parallel to def_hash and module_hash).

Three coherence checks fire from build_registry, each surfacing as a
distinct WorkspaceLoadError variant:

- OrphanInstance: `instance C T` not in C's or T's defining module.
- DuplicateInstance: two entries share the same (class, type-hash) key.
- MissingMethod: instance omits a required (non-default) method.

The CLI's workspace_error_to_diagnostic is extended with the three new
codes (orphan-instance / duplicate-instance / missing-method).

All existing Workspace { ... } construction sites get the new
`registry` field, defaulting to Registry::default() at synthetic /
test-only paths and threading through ws.registry.clone() at codepath
sites that already hold a real workspace.

Includes hash-stability regression tests (iter22b1_schema_extension_*
and iter22b1_classdef_empty_optionals_hash_stable) and the empty-
registry positive test against examples/sum.ail.json. Test count:
288 → 291 (3 new tests, all pass).
This commit is contained in:
2026-05-09 12:37:00 +02:00
parent f25d7b6cd6
commit eb4db9dafc
8 changed files with 442 additions and 2 deletions
+17
View File
@@ -46,6 +46,23 @@ pub fn to_bytes<T: serde::Serialize>(value: &T) -> Vec<u8> {
out
}
/// Iter 22b.1: 16-hex-char hash of a [`crate::ast::Type`] in isolation.
///
/// Used by [`crate::workspace::Registry`] to key `InstanceDef`s by
/// their target type. Parallel in shape to [`crate::def_hash`] and
/// [`crate::workspace::module_hash`]: BLAKE3 over canonical-JSON
/// bytes, truncated to 16 hex chars.
///
/// The key property is determinism — two `Type` values that are
/// canonically equal MUST hash identically — so the workspace
/// registry's "is `instance C T` already declared?" check is
/// representation-independent.
pub fn type_hash(t: &crate::ast::Type) -> String {
let bytes = to_bytes(t);
let h = blake3::hash(&bytes);
h.to_hex().as_str()[..16].to_string()
}
fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> std::io::Result<()> {
use serde_json::Value;
match v {
+56
View File
@@ -181,4 +181,60 @@ mod tests {
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
}
/// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance`
/// must NOT change canonical-JSON hashes of any pre-22b def. The
/// new variants are alternatives, not field additions — pre-22b
/// fixtures simply do not produce a `Class` / `Instance` `Def`,
/// and the existing `Fn` / `Const` / `Type` arms are unchanged.
/// This re-asserts the same on-disk hashes the 13a / 19b
/// regressions pinned, after the 22b.1 schema bump.
#[test]
fn iter22b1_schema_extension_preserves_pre_22b_hashes() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let examples = manifest_dir.join("../../examples");
let sum_src = std::fs::read(examples.join("sum.ail.json"))
.expect("examples/sum.ail.json present");
let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
let list_src = std::fs::read(examples.join("list.ail.json"))
.expect("examples/list.ail.json present");
let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
let int_list_def = list_mod
.defs
.iter()
.find(|d| d.name() == "IntList")
.unwrap();
assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
}
/// Iter 22b.1: a [`crate::ast::ClassDef`] with no doc, no
/// superclass, and an empty methods list serialises to a stable
/// canonical form. Two construction paths (default-elided
/// optionals vs. JSON without those keys at all) hash
/// bit-identically. If FAIL: a `skip_serializing_if` is missing
/// on `superclass` or `doc`, or the parser produces different
/// canonical bytes from the same logical value.
#[test]
fn iter22b1_classdef_empty_optionals_hash_stable() {
let bare = Def::Class(crate::ast::ClassDef {
name: "Empty".into(),
param: "a".into(),
superclass: None,
methods: vec![],
doc: None,
});
let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#;
let parsed: Def = serde_json::from_str(json).unwrap();
assert_eq!(
def_hash(&bare),
def_hash(&parsed),
"ClassDef with elided optionals must hash identically to construction with explicit None"
);
}
}
+259 -2
View File
@@ -31,10 +31,10 @@
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
//! ```
use crate::ast::Module;
use crate::ast::{ClassDef, Def, InstanceDef, Module, Type};
use crate::canonical;
use crate::{load_module, Error as CoreError};
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
/// Fully loaded workspace.
@@ -43,6 +43,11 @@ use std::path::{Path, PathBuf};
/// transitively reachable modules are contained in `modules` and indexed
/// by `Module.name`. `root_dir` is the directory the entry file lives
/// in; all imports are resolved relative to it.
///
/// Iter 22b.1 (Decision 11): `registry` is the workspace-global
/// instance registry, built at the end of [`load_workspace`] after the
/// DFS over imports completes. It is empty for any workspace whose
/// modules contain no [`crate::ast::Def::Instance`] defs.
#[derive(Debug, Clone)]
pub struct Workspace {
/// Name of the entry module (the one passed to [`load_workspace`]).
@@ -54,6 +59,42 @@ pub struct Workspace {
/// Directory the entry file lives in; all imports are resolved
/// relative to it.
pub root_dir: PathBuf,
/// Iter 22b.1: workspace-global typeclass instance registry.
pub registry: Registry,
}
/// Iter 22b.1: workspace-global instance registry (Decision 11).
///
/// Built at the end of [`load_workspace`] after all modules are
/// loaded. Keyed by `(class-name, canonical-type-hash)`; values are
/// the matching [`crate::ast::InstanceDef`] plus the name of the
/// module it was declared in. The hash key uses
/// [`canonical::type_hash`], so the key is stable against unrelated
/// whitespace / field-order differences in the source JSON.
///
/// 22b.1 enforces three coherence checks during build:
///
/// 1. **Coherence (orphan-freedom).** Every `instance C T` lives in
/// the module of `C` or in the module of `T` (per Decision 11
/// axis 3). Otherwise → [`WorkspaceLoadError::OrphanInstance`].
/// 2. **Uniqueness.** No two entries share a key. Otherwise →
/// [`WorkspaceLoadError::DuplicateInstance`].
/// 3. **Method completeness.** Each instance specifies a body for
/// every required (non-default) method of its class. Otherwise →
/// [`WorkspaceLoadError::MissingMethod`].
#[derive(Debug, Clone, Default)]
pub struct Registry {
/// Map from `(class-name, type-hash)` to the registry entry.
pub entries: BTreeMap<(String, String), RegistryEntry>,
}
/// One entry in the [`Registry`].
#[derive(Debug, Clone)]
pub struct RegistryEntry {
/// The instance declaration itself.
pub instance: InstanceDef,
/// Name of the module the instance was declared in.
pub defining_module: String,
}
/// Structured errors of the workspace loader.
@@ -110,6 +151,54 @@ pub enum WorkspaceLoadError {
"module `{name}` was loaded twice with differing content (hashes differ)"
)]
ModuleHashMismatch { name: String },
/// Iter 22b.1: an [`crate::ast::Def::Instance`] was declared in a
/// module that is neither the class's defining module nor the
/// instance type's defining module. Coherence violation per
/// Decision 11 axis 3 ("orphan-freedom"). The lookup is hard:
/// AILang does not provide a `--allow-orphans` flag.
#[error(
"orphan instance: `instance {class} {type_repr}` declared in module `{defining_module}`, \
but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there"
)]
OrphanInstance {
class: String,
type_repr: String,
defining_module: String,
class_module: String,
type_module: String,
},
/// Iter 22b.1: two [`crate::ast::Def::Instance`]s share the same
/// `(class, canonical-type-hash)` key. Coherence requires
/// uniqueness; the registry has no way to disambiguate at
/// resolution time. Per Decision 11 there is no
/// `AmbiguousInstance` diagnostic — coherence makes the lookup
/// unambiguous by construction, and a duplicate is a workspace
/// configuration error, not a per-call-site one.
#[error(
"duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`"
)]
DuplicateInstance {
class: String,
type_repr: String,
first_module: String,
second_module: String,
},
/// Iter 22b.1: an [`crate::ast::Def::Instance`] does not specify
/// a body for a required (non-default) method of its class.
/// Default-bearing methods may be inherited; non-default
/// (abstract-required) methods must be specified by every
/// instance. Per Decision 11 §"Defaults and superclasses".
#[error(
"instance `{class} {type_repr}` is missing a body for method `{method}` (no default)"
)]
MissingMethod {
class: String,
type_repr: String,
method: String,
},
}
/// Hash over the canonical bytes of a complete module.
@@ -161,13 +250,163 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
&mut visiting_set,
)?;
// Iter 22b.1: build the workspace-global instance registry. Three
// coherence checks fire here (Orphan / Duplicate / Missing-method);
// any violation is surfaced as a `WorkspaceLoadError`, not as a
// per-call-site diagnostic.
let registry = build_registry(&modules)?;
Ok(Workspace {
entry: entry_name,
modules,
root_dir,
registry,
})
}
/// Iter 22b.1: build the workspace-global typeclass instance registry.
///
/// Two passes:
///
/// 1. Scan every loaded module to build a "where is X defined" lookup
/// for class names and type names — needed for the coherence
/// (orphan) check below.
/// 2. For each [`crate::ast::Def::Instance`] in declaration order,
/// apply the three checks (coherence / uniqueness / method
/// completeness) and insert the entry on success.
///
/// Iteration is over the modules `BTreeMap` (alphabetical by name) so
/// the order in which collisions are detected is deterministic across
/// runs. Within a module, defs are scanned in source order.
fn build_registry(
modules: &BTreeMap<String, Module>,
) -> Result<Registry, WorkspaceLoadError> {
// Pass 1: collect "where is X defined" maps, plus a class lookup
// by name (needed for the method-completeness check).
let mut class_def_module: BTreeMap<String, String> = BTreeMap::new();
let mut type_def_module: BTreeMap<String, String> = BTreeMap::new();
let mut class_by_name: BTreeMap<String, &ClassDef> = BTreeMap::new();
for (mod_name, m) in modules {
for def in &m.defs {
match def {
Def::Class(c) => {
class_def_module.insert(c.name.clone(), mod_name.clone());
class_by_name.insert(c.name.clone(), c);
}
Def::Type(t) => {
type_def_module.insert(t.name.clone(), mod_name.clone());
}
_ => {}
}
}
}
// Pass 2: register each instance, with coherence / uniqueness /
// method-completeness checks.
let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new();
for (mod_name, m) in modules {
for def in &m.defs {
if let Def::Instance(inst) = def {
let type_repr = type_head_name(&inst.type_);
// Coherence (orphan-freedom): instance's module must
// equal the class's module or the type's module. A
// class declared inside the same module as the
// instance always satisfies the first leg; a
// user-defined type declared in the instance's module
// satisfies the second. Primitives have no
// user-defined module — instances on primitives
// therefore must live in the class's module.
let class_mod = class_def_module
.get(&inst.class)
.cloned()
.unwrap_or_else(|| "<unknown-class>".into());
let type_mod = type_def_module
.get(&type_repr)
.cloned()
.unwrap_or_else(|| "<primitive-or-unknown>".into());
let coherent = mod_name == &class_mod || mod_name == &type_mod;
if !coherent {
return Err(WorkspaceLoadError::OrphanInstance {
class: inst.class.clone(),
type_repr,
defining_module: mod_name.clone(),
class_module: class_mod,
type_module: type_mod,
});
}
// Uniqueness: a `(class, type-hash)` key must appear
// at most once across the whole workspace.
let type_hash = canonical::type_hash(&inst.type_);
let key = (inst.class.clone(), type_hash);
if let Some(prior) = entries.get(&key) {
return Err(WorkspaceLoadError::DuplicateInstance {
class: inst.class.clone(),
type_repr,
first_module: prior.defining_module.clone(),
second_module: mod_name.clone(),
});
}
// Method completeness: every non-default method of
// the class must have a body in this instance.
// Defaults may be inherited (no body required).
// Missing class declaration is deferred to 22b.2's
// typecheck arms — for 22b.1 we skip the
// completeness check rather than firing a separate
// diagnostic.
if let Some(class_def) = class_by_name.get(&inst.class) {
let provided: BTreeSet<&str> =
inst.methods.iter().map(|m| m.name.as_str()).collect();
for class_method in &class_def.methods {
if class_method.default.is_none()
&& !provided.contains(class_method.name.as_str())
{
return Err(WorkspaceLoadError::MissingMethod {
class: inst.class.clone(),
type_repr,
method: class_method.name.clone(),
});
}
}
}
entries.insert(
key,
RegistryEntry {
instance: inst.clone(),
defining_module: mod_name.clone(),
},
);
}
}
}
Ok(Registry { entries })
}
/// Iter 22b.1: extract the head-constructor name of a type, for the
/// "where is this type defined" lookup and for diagnostic-message
/// rendering.
///
/// For `Type::Con { name, .. }` (the only legal head shape for a
/// non-orphan instance) returns `name`. Other variants (`Var`,
/// `Forall`, `Fn`) are not legal as instance heads — Decision 11
/// requires a concrete type expression at the instance head — so we
/// emit a stable fallback string. The fallback prevents diagnostic
/// rendering from panicking on a malformed fixture; the "real"
/// rejection of non-concrete instance heads will arrive as a
/// schema-validation diagnostic in 22b.2.
fn type_head_name(t: &Type) -> String {
match t {
Type::Con { name, .. } => name.clone(),
Type::Var { name } => format!("<var:{name}>"),
Type::Forall { .. } => "<forall>".into(),
Type::Fn { .. } => "<fn>".into(),
}
}
fn visit(
module: Module,
root_dir: &Path,
@@ -352,4 +591,22 @@ mod tests {
other => panic!("expected ModuleNotFound, got {other:?}"),
}
}
/// Iter 22b.1: a workspace whose modules contain no
/// `Def::Instance` defs has an empty registry. This is the
/// happy-path baseline for the registry-build pass — every
/// pre-22b workspace falls into this case.
#[test]
fn iter22b1_workspace_with_no_classes_has_empty_registry() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace_root =
Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace_root.join("examples").join("sum.ail.json");
let ws = load_workspace(&entry).expect("sum.ail.json loads");
assert!(
ws.registry.entries.is_empty(),
"pre-22b fixture has no class/instance defs, registry must be empty"
);
}
}