From 522500a6f67a8bd5e5babc7bc378de402d7709dd Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 9 May 2026 12:24:48 +0200 Subject: [PATCH] plan: 22b.1 typeclass schema floor + registry (writing-plans skill experiment) --- ...2026-05-09-22b.1-typeclass-schema-floor.md | 927 ++++++++++++++++++ 1 file changed, 927 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md diff --git a/docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md b/docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md new file mode 100644 index 0000000..2405130 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md @@ -0,0 +1,927 @@ +# 22b.1 — Typeclass Schema Floor + Registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the AILang JSON schema for `ClassDef` and `InstanceDef` and the workspace-load-time instance registry with coherence/uniqueness/completeness checks. After this iter, a workspace containing classes and instances loads cleanly (or errors with the right diagnostic), but no typecheck arm reads them yet. + +**Architecture:** Two new variants on `Def` (`Class`, `Instance`) following the existing additive-schema pattern. A new `Registry` struct built during `load_workspace` after the DFS. Three new `WorkspaceLoadError` variants. Hash-stability is verified by a regression test against on-disk pre-22b fixtures and a same-shape-different-paths test. + +**Tech Stack:** Rust 2021, `serde` + `serde_json` for AST (de)serialisation, `blake3` for canonical hashing (already in tree), `thiserror` for error enums. No new dependencies. + +**Spec reference:** `docs/DESIGN.md` Decision 11 (committed as commit `f6cb900`). The five committed semantic axes plus the Form-A schema in §"Form-A schema" are the authoritative spec for this iter. + +**Iter scope (what this plan does NOT cover, deferred to 22b.2/3/4):** +- `FnDef.type.constraints` field — 22b.2 +- Class-schema validation diagnostics (`KindMismatch`, `InvalidSuperclassParam`, `ConstraintReferencesUnboundTypeVar`) — 22b.2 +- Typecheck arms `MissingConstraint`, `NoInstance` — 22b.2 +- Monomorphisation pass — 22b.3 +- Prelude module + `print` rewiring — 22b.4 +- Form-B prose projection of class/instance — 22b.4 or later +- Surface (Form-B) parser/printer arms for class/instance — 22b.4 or later + +--- + +### Task 1: Add `ClassDef` and `InstanceDef` AST structs + +**Files:** +- Modify: `crates/ailang-core/src/ast.rs` (add structs, extend `Def` enum, extend `Def::name()` and `def_kind()`) + +- [ ] **Step 1.1: Add `ClassDef` struct after `Suppress` (around line 222)** + +```rust +/// Iter 22b.1: a typeclass declaration (Decision 11). +/// +/// Single-parameter, multi-method, optional-default, optional-superclass +/// typeclass. The `param` is a single string (multi-param classes are +/// not supported per Decision 11 axis 1; the schema enforces this by +/// shape — `param` is `String`, not `Vec`). +/// +/// `superclass`, when present, MUST have its `type` field equal to +/// `param`. The check is enforced in 22b.2 by the +/// `InvalidSuperclassParam` diagnostic; the schema does not encode it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassDef { + /// Class name (capitalised by convention). + pub name: String, + /// Single type-parameter name. + pub param: String, + /// Optional single-superclass relation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub superclass: Option, + /// Methods of the class, in declaration order. + pub methods: Vec, + /// Optional source-level documentation string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc: Option, +} + +/// Iter 22b.1: reference to a superclass relation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SuperclassRef { + /// Superclass name. + pub class: String, + /// Type the superclass is applied to. MUST equal the parent + /// `ClassDef.param` (validated in 22b.2 — schema does not enforce). + #[serde(rename = "type")] + pub type_: String, +} + +/// Iter 22b.1: one method declared in a [`ClassDef`]. +/// +/// `default` is `None` when the method is abstract-required (every +/// instance must specify it); `Some(body)` when the method has a +/// default body (instances may inherit or override). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassMethod { + /// Method name. + pub name: String, + /// Full method signature including mode annotations. + #[serde(rename = "type")] + pub ty: Type, + /// Default body. `None` ⇒ abstract-required; `Some(body)` ⇒ + /// default-with-fallback. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +/// Iter 22b.1: an instance declaration (Decision 11). +/// +/// `class` is the name of the class being instantiated. `type_` is the +/// concrete type expression the class is applied to — never the class +/// param. `methods` contains bodies for the required methods plus any +/// overrides of default-bearing methods. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceDef { + /// Class being instantiated. + pub class: String, + /// Concrete type the class is applied to. + #[serde(rename = "type")] + pub type_: Type, + /// Method bodies in declaration order. + pub methods: Vec, + /// Optional source-level documentation string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc: Option, +} + +/// Iter 22b.1: one method body in an [`InstanceDef`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceMethod { + /// Method name (must match a method in the corresponding class). + pub name: String, + /// Method body. The class's declared method type with the class + /// param substituted to the instance type is the body's expected + /// type — checked in 22b.2. + pub body: Term, +} +``` + +- [ ] **Step 1.2: Extend `Def` enum with new variants (line 65)** + +Replace the existing `Def` enum: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Def { + /// Function definition; see [`FnDef`]. + Fn(FnDef), + /// Constant definition; see [`ConstDef`]. + Const(ConstDef), + /// Type (ADT) definition; see [`TypeDef`]. + Type(TypeDef), + /// Iter 22b.1: typeclass declaration; see [`ClassDef`]. + Class(ClassDef), + /// Iter 22b.1: instance declaration; see [`InstanceDef`]. + Instance(InstanceDef), +} +``` + +- [ ] **Step 1.3: Extend `Def::name()` and `def_kind()`** + +```rust +impl Def { + pub fn name(&self) -> &str { + match self { + Def::Fn(f) => &f.name, + Def::Const(c) => &c.name, + Def::Type(t) => &t.name, + Def::Class(c) => &c.name, + Def::Instance(i) => &i.class, // an instance is "named" by its class + } + } +} + +pub fn def_kind(def: &Def) -> &'static str { + match def { + Def::Fn(_) => "fn", + Def::Const(_) => "const", + Def::Type(_) => "type", + Def::Class(_) => "class", + Def::Instance(_) => "instance", + } +} +``` + +- [ ] **Step 1.4: Run `cargo check -p ailang-core`** + +Expected: compiles cleanly. Any non-exhaustive-match warning in dependent crates surfaces here as a downstream-crate compile error — those are addressed in Task 2. + +--- + +### Task 2: Update downstream `match Def::*` sites + +The new `Def` variants force all match-on-Def sites to be updated. Find them and add explicit arms (typically returning a "not yet handled" placeholder for 22b.1, with the real handling deferred to later sub-iters). + +- [ ] **Step 2.1: Find all match sites** + +Run: `cargo build 2>&1 | grep -B1 -A2 "non-exhaustive\|missing match arm"` + +Expected: a list of files with non-exhaustive `match` against `Def`. Likely candidates: `crates/ailang-check/src/lib.rs`, `crates/ailang-codegen/src/lib.rs`, `crates/ailang-prose/src/lib.rs`, `crates/ailang-surface/src/print.rs`, `crates/ail/src/main.rs` (workspace, diff, manifest subcommands), `crates/ailang-core/src/canonical.rs`, `crates/ailang-core/src/desugar.rs`, `crates/ailang-core/src/pretty.rs`. + +- [ ] **Step 2.2: For each site, add explicit `Class` and `Instance` arms** + +Default behaviour for 22b.1 (deferred to later sub-iters): +- **Typecheck (`ailang-check`)**: skip (do not check the class/instance arms yet). Add a TODO comment naming the deferred 22b.2 work. +- **Codegen (`ailang-codegen`)**: skip emission. Class/instance produces no IR until 22b.3 monomorphisation. Add a TODO comment. +- **Prose projection (`ailang-prose`)**: emit a placeholder marker `(class …)` / `(instance …)` so the round-trip test survives. The real projection lands in 22b.4 (or its own iter). +- **Surface print (`ailang-surface/src/print.rs`)**: same — placeholder. +- **CLI subcommands**: `ail manifest` lists the class/instance by `def_kind`; `ail diff` treats class/instance as opaque-but-structural (compare canonical hashes only). +- **Pretty (`ailang-core/src/pretty.rs`)**: emit a one-line summary like `class Show a` / `instance Show Int`. +- **Canonical (`ailang-core/src/canonical.rs`)**: serde already gives canonical bytes; no special handling needed. +- **Desugar (`ailang-core/src/desugar.rs`)**: pass through unchanged. + +Concrete example for `ailang-check/src/lib.rs` (find the `for def in module.defs` loop): + +```rust +for def in &module.defs { + match def { + Def::Fn(f) => check_fn(f, ...)?, + Def::Const(c) => check_const(c, ...)?, + Def::Type(t) => check_type(t, ...)?, + Def::Class(_) | Def::Instance(_) => { + // 22b.1: schema-only landing. Class-schema validation + // (KindMismatch, InvalidSuperclassParam, ...) and + // instance-body typechecking land in 22b.2. + } + } +} +``` + +- [ ] **Step 2.3: Run `cargo build`** + +Expected: builds cleanly across the workspace. No warnings. + +- [ ] **Step 2.4: Run `cargo test`** + +Expected: all 288 existing tests pass, 3 ignored. No new test failures (no new tests yet). + +- [ ] **Step 2.5: Commit** + +```bash +git add crates/ailang-core/src/ast.rs crates/ailang-check/src/ crates/ailang-codegen/src/ crates/ailang-prose/src/ crates/ailang-surface/src/ crates/ail/src/main.rs crates/ailang-core/src/canonical.rs crates/ailang-core/src/desugar.rs crates/ailang-core/src/pretty.rs +git commit -m "iter 22b.1.1: ClassDef/InstanceDef AST + downstream match arms (schema-only)" +``` + +--- + +### Task 3: Hash-stability regression tests + +The schema additions must not change canonical-JSON hashes of any pre-22b definition. Two regression tests follow the established pattern from `iter13a_schema_extension_preserves_pre_13a_hashes` and `iter19b_schema_extension_preserves_pre_19b_hashes` in `crates/ailang-core/src/hash.rs`. + +**Files:** +- Modify: `crates/ailang-core/src/hash.rs` (add two tests inside the existing `#[cfg(test)] mod tests` block) + +- [ ] **Step 3.1: Write hash-stability test against on-disk fixtures** + +Append to the test module in `hash.rs`: + +```rust +/// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance` +/// must NOT change canonical-JSON hashes of any pre-22b def. Pre-22b +/// fixtures had no class/instance variants, so all on-disk fns/types +/// must hash bit-identically to the values recorded by 13a/19b. +#[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"); +} +``` + +- [ ] **Step 3.2: Run the test to verify it passes** + +Run: `cargo test -p ailang-core iter22b1_schema_extension_preserves_pre_22b_hashes` + +Expected: PASS. Pre-22b fixtures hash identically because the new `Def` variants are alternatives, not field additions — pre-22b fixtures simply don't use them. + +- [ ] **Step 3.3: Write a same-shape-different-paths test for the new variants themselves** + +```rust +/// Iter 22b.1: a `ClassDef` with no doc, no superclass, and an empty +/// methods list serialises to a stable canonical form. Two construction +/// paths (default-elided field vs. explicit-None field) hash identically. +#[test] +fn iter22b1_classdef_empty_optionals_hash_stable() { + use crate::ast::{ClassDef, Def}; + + let bare = Def::Class(ClassDef { + name: "Empty".into(), + param: "a".into(), + superclass: None, + methods: vec![], + doc: None, + }); + + // The same value, but constructed from JSON without the optional + // fields present at all. + 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" + ); +} +``` + +- [ ] **Step 3.4: Run the test** + +Run: `cargo test -p ailang-core iter22b1_classdef_empty_optionals_hash_stable` + +Expected: PASS. If FAIL: a `skip_serializing_if` is missing on one of `superclass`, `doc`, or the parser is producing different bytes from the same value. + +- [ ] **Step 3.5: Commit** + +```bash +git add crates/ailang-core/src/hash.rs +git commit -m "iter 22b.1.2: hash-stability regression tests for ClassDef/InstanceDef" +``` + +--- + +### Task 4: Workspace-load-time instance registry + +The registry maps `(class-name, canonical-hash-of-instance-type)` to an instance plus the module that defined it. It is built at workspace-load time after the DFS completes. + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs` (add `Registry` struct, `RegistryEntry`, build during `load_workspace`) + +- [ ] **Step 4.1: Write a failing test for an empty registry** + +Append to the existing `#[cfg(test)] mod tests` block in `workspace.rs` (or create one if absent): + +```rust +#[test] +fn iter22b1_workspace_with_no_classes_has_empty_registry() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + let entry = 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" + ); +} +``` + +- [ ] **Step 4.2: Run the test to verify it fails** + +Run: `cargo test -p ailang-core iter22b1_workspace_with_no_classes_has_empty_registry` + +Expected: FAIL with "no field `registry` on Workspace" (or similar). The struct doesn't have the field yet. + +- [ ] **Step 4.3: Add `Registry` struct and field** + +In `workspace.rs`, after the `Workspace` struct: + +```rust +/// Iter 22b.1: workspace-global instance registry. +/// +/// Built at the end of [`load_workspace`] after all modules are loaded. +/// Keyed by `(class-name, canonical-type-hash)`; values are the +/// `InstanceDef` plus the name of the module it was declared in. The +/// hash key uses the same canonical-byte algorithm as [`def_hash`] but +/// applied only to the instance's `type_` field, so the key is stable +/// against unrelated whitespace / field-order differences. +#[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: ast::InstanceDef, + /// Name of the module the instance was declared in. + pub defining_module: String, +} +``` + +Add `pub registry: Registry,` to the `Workspace` struct. + +In `load_workspace`, before the final `Ok(Workspace { ... })`, build the registry: + +```rust +let registry = build_registry(&modules)?; +Ok(Workspace { + entry: entry_name, + modules, + root_dir, + registry, +}) +``` + +Add a stub `build_registry`: + +```rust +fn build_registry( + modules: &BTreeMap, +) -> Result { + 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_hash = canonical::type_hash(&inst.type_); + let key = (inst.class.clone(), type_hash); + entries.insert( + key, + RegistryEntry { + instance: inst.clone(), + defining_module: mod_name.clone(), + }, + ); + } + } + } + Ok(Registry { entries }) +} +``` + +This uses `canonical::type_hash` — a function we need to add. See Step 4.4. + +- [ ] **Step 4.4: Add `canonical::type_hash`** + +In `crates/ailang-core/src/canonical.rs`: + +```rust +/// Iter 22b.1: hash a [`Type`] in isolation. +/// +/// Used by the workspace registry to key `InstanceDef`s by their target +/// type. The 16-hex-char prefix matches the convention of [`def_hash`] +/// and [`module_hash`]. +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() +} +``` + +(`to_bytes` is the existing serialiser-to-canonical-bytes function; `Type` already implements `Serialize`.) + +- [ ] **Step 4.5: Run the test to verify it passes** + +Run: `cargo test -p ailang-core iter22b1_workspace_with_no_classes_has_empty_registry` + +Expected: PASS. + +- [ ] **Step 4.6: Commit** + +```bash +git add crates/ailang-core/src/workspace.rs crates/ailang-core/src/canonical.rs +git commit -m "iter 22b.1.3: workspace-load-time instance registry (empty case)" +``` + +--- + +### Task 5: Coherence check (orphan detection) + +The registry-build phase rejects orphan instances: an `instance C T` declared in a module that is neither `C`'s defining module nor `T`'s defining module. + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs` (extend `WorkspaceLoadError`, extend `build_registry`) +- Create: `examples/test_22b1_orphan_class.ail.json`, `examples/test_22b1_orphan_type.ail.json`, `examples/test_22b1_orphan_third.ail.json` (three test fixtures) + +- [ ] **Step 5.1: Write a failing test** + +```rust +#[test] +fn iter22b1_orphan_instance_fires_diagnostic() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + let entry = examples.join("test_22b1_orphan_third.ail.json"); + + let err = load_workspace(&entry).expect_err("must fire orphan"); + match err { + WorkspaceLoadError::OrphanInstance { class, type_repr, defining_module, .. } => { + assert_eq!(class, "Show"); + assert_eq!(type_repr, "Int"); + assert_eq!(defining_module, "test_22b1_orphan_third"); + } + other => panic!("expected OrphanInstance, got {:?}", other), + } +} +``` + +- [ ] **Step 5.2: Run to verify failure** + +Run: `cargo test -p ailang-core iter22b1_orphan_instance_fires_diagnostic` + +Expected: FAIL with "no variant `OrphanInstance` on `WorkspaceLoadError`" or "fixture not found". + +- [ ] **Step 5.3: Add `OrphanInstance` variant** + +In `WorkspaceLoadError`: + +```rust +/// Iter 22b.1: an `InstanceDef` 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. +#[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, +}, +``` + +- [ ] **Step 5.4: Extend `build_registry` with the coherence check** + +```rust +fn build_registry( + modules: &BTreeMap, +) -> Result { + // First pass: build a "where is X defined" lookup for both classes and types. + let mut class_def_module: BTreeMap = BTreeMap::new(); + let mut type_def_module: BTreeMap = 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()); } + Def::Type(t) => { type_def_module.insert(t.name.clone(), mod_name.clone()); } + _ => {} + } + } + } + + 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 { + // Coherence: instance must be in class's module or type's module. + let class_mod = class_def_module + .get(&inst.class) + .cloned() + .unwrap_or_else(|| "".into()); + let type_repr = type_head_name(&inst.type_); + let type_mod = type_def_module + .get(&type_repr) + .cloned() + .unwrap_or_else(|| "".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, + }); + } + + let type_hash = canonical::type_hash(&inst.type_); + let key = (inst.class.clone(), type_hash); + 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 +/// purposes of "where is this type defined" lookup. For `Type::Con +/// { name, args }` returns `name`; for primitive types returns the +/// primitive name (`"Int"`, `"Float"`, etc.). Type variables and +/// `Type::Forall` are not legal as instance heads — they would have +/// been rejected at parse time (the schema for `InstanceDef.type_` +/// requires a concrete type expression). +fn type_head_name(t: &crate::ast::Type) -> String { + use crate::ast::Type; + match t { + Type::Con { name, .. } => name.clone(), + Type::Int => "Int".into(), + Type::Float => "Float".into(), + Type::Bool => "Bool".into(), + Type::String => "String".into(), + // Other variants (Var, Forall, Fn, ...) are rejected at parse + // time per the InstanceDef schema; if we reach here it's an + // internal error, but we surface a stable string so the + // diagnostic is at least readable. + _ => "".into(), + } +} +``` + +(Note: the exact `Type` enum variants come from `crates/ailang-core/src/ast.rs:434`. Match arms must be exhaustive; if your AILang version has additional variants, add them here. The implementer should grep `Type::` in `ast.rs` to confirm.) + +- [ ] **Step 5.5: Build test fixtures** + +Create `examples/test_22b1_orphan_class.ail.json`: a one-module workspace where `Show` and `instance Show Int` both live in the same module (the class's module). Coherence holds. + +Concrete content (skeleton — actual schema follows existing examples like `examples/sum.ail.json`): + +```json +{ + "name": "test_22b1_orphan_class", + "imports": [], + "defs": [ + { + "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { "name": "show", "type": { "Fn": { "params": [{ "Var": "a" }], "return": "String" } } } + ] + }, + { + "kind": "instance", + "class": "Show", + "type": "Int", + "methods": [ + { "name": "show", "body": { "t": "lit", "lit": { "Str": "" } } } + ] + } + ] +} +``` + +(The `Type` JSON encoding shown is illustrative — the implementer should consult the existing `examples/list.ail.json` for the actual `Type::Fn` JSON form.) + +Create `examples/test_22b1_orphan_type.ail.json`: instance lives in the type's module (also coherent). + +Create `examples/test_22b1_orphan_third.ail.json`: instance lives in a third module that is neither the class's nor the type's. This MUST fire `OrphanInstance`. + +- [ ] **Step 5.6: Run the test to verify it passes** + +Run: `cargo test -p ailang-core iter22b1_orphan_instance_fires_diagnostic` + +Expected: PASS. + +- [ ] **Step 5.7: Add positive tests (coherent instance loads)** + +```rust +#[test] +fn iter22b1_instance_in_class_module_loads_clean() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + let entry = examples.join("test_22b1_orphan_class.ail.json"); + + let ws = load_workspace(&entry).expect("coherent instance loads"); + assert_eq!(ws.registry.entries.len(), 1); + let (key, entry) = ws.registry.entries.iter().next().unwrap(); + assert_eq!(&key.0, "Show"); + assert_eq!(entry.defining_module, "test_22b1_orphan_class"); +} +``` + +- [ ] **Step 5.8: Run the positive test** + +Run: `cargo test -p ailang-core iter22b1_instance_in_class_module_loads_clean` + +Expected: PASS. + +- [ ] **Step 5.9: Commit** + +```bash +git add crates/ailang-core/src/workspace.rs examples/test_22b1_orphan_*.ail.json +git commit -m "iter 22b.1.4: coherence check (orphan-instance detection)" +``` + +--- + +### Task 6: Uniqueness check (duplicate-instance detection) + +Two `InstanceDef`s with the same `(class, type-hash)` key are a coherence violation. + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs` +- Create: `examples/test_22b1_dup_instance_a.ail.json`, `examples/test_22b1_dup_instance_b.ail.json`, `examples/test_22b1_dup_entry.ail.json` + +- [ ] **Step 6.1: Write failing test** + +```rust +#[test] +fn iter22b1_duplicate_instance_fires_diagnostic() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + let entry = examples.join("test_22b1_dup_entry.ail.json"); + + let err = load_workspace(&entry).expect_err("must fire duplicate"); + match err { + WorkspaceLoadError::DuplicateInstance { class, type_repr, first_module, second_module } => { + assert_eq!(class, "Show"); + assert_eq!(type_repr, "Int"); + assert_ne!(first_module, second_module); + } + other => panic!("expected DuplicateInstance, got {:?}", other), + } +} +``` + +- [ ] **Step 6.2: Run to verify failure** + +Run: `cargo test -p ailang-core iter22b1_duplicate_instance_fires_diagnostic` + +Expected: FAIL ("no variant `DuplicateInstance`"). + +- [ ] **Step 6.3: Add `DuplicateInstance` variant** + +```rust +/// Iter 22b.1: two `InstanceDef`s share the same (class, type-hash) +/// key. Coherence requires uniqueness; the registry has no way to +/// disambiguate at resolution time. +#[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, +}, +``` + +- [ ] **Step 6.4: Extend `build_registry` with the check** + +In the `for def in &m.defs { if let Def::Instance(inst) = def { ... } }` block, replace the `entries.insert(key, ...)` line with: + +```rust +let key = (inst.class.clone(), type_hash); +if let Some(prior) = entries.get(&key) { + return Err(WorkspaceLoadError::DuplicateInstance { + class: inst.class.clone(), + type_repr: type_head_name(&inst.type_), + first_module: prior.defining_module.clone(), + second_module: mod_name.clone(), + }); +} +entries.insert( + key, + RegistryEntry { + instance: inst.clone(), + defining_module: mod_name.clone(), + }, +); +``` + +- [ ] **Step 6.5: Build test fixtures** + +`examples/test_22b1_dup_instance_a.ail.json`: declares `class Show` and `instance Show Int`. +`examples/test_22b1_dup_instance_b.ail.json`: declares another `instance Show Int` (this module owns no `Int`-relevant type, but coherence is satisfied because... wait — actually this is a tricky setup; if Int is primitive, only the class's module can own instances for it. So the duplicate must be in the class's module, but that's syntactically one module). + +Better setup: define a user type `MyInt` in module B; declare `class Show` in module A; declare `instance Show MyInt` in BOTH module A (legal: Show's module) and module B (legal: MyInt's module). Both are coherent individually but their existence together is a duplicate. + +`examples/test_22b1_dup_entry.ail.json`: imports A and B. + +- [ ] **Step 6.6: Run the test** + +Run: `cargo test -p ailang-core iter22b1_duplicate_instance_fires_diagnostic` + +Expected: PASS. + +- [ ] **Step 6.7: Commit** + +```bash +git add crates/ailang-core/src/workspace.rs examples/test_22b1_dup_*.ail.json +git commit -m "iter 22b.1.5: uniqueness check (duplicate-instance detection)" +``` + +--- + +### Task 7: Method-completeness check + +An `InstanceDef` must specify a body for every required (non-default) method of its class. This check needs the class definition; it runs after the registry pass collects classes. + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs` +- Create: `examples/test_22b1_missing_method.ail.json` + +- [ ] **Step 7.1: Write failing test** + +```rust +#[test] +fn iter22b1_missing_method_fires_diagnostic() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + let entry = examples.join("test_22b1_missing_method.ail.json"); + + let err = load_workspace(&entry).expect_err("must fire missing-method"); + match err { + WorkspaceLoadError::MissingMethod { class, type_repr, method } => { + assert_eq!(class, "Eq"); + assert_eq!(type_repr, "Int"); + assert_eq!(method, "eq"); + } + other => panic!("expected MissingMethod, got {:?}", other), + } +} +``` + +- [ ] **Step 7.2: Run to verify failure** + +Expected: FAIL ("no variant `MissingMethod`"). + +- [ ] **Step 7.3: Add `MissingMethod` variant** + +```rust +/// Iter 22b.1: an `InstanceDef` does not specify a body for a +/// required (non-default) method of its class. +#[error( + "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)" +)] +MissingMethod { + class: String, + type_repr: String, + method: String, +}, +``` + +- [ ] **Step 7.4: Extend `build_registry`** + +Build a `class_methods: BTreeMap` lookup in the same first pass as `class_def_module`. Then, after registering a non-orphan, non-duplicate instance: + +```rust +// Method-completeness check. +let class_def = match class_methods.get(&inst.class) { + Some(c) => c, + None => { + // Class not declared anywhere — defer to 22b.2 typecheck for + // the "unknown class" diagnostic. For 22b.1 we just skip the + // completeness check. + // (Alternative: fire a NoSuchClass diagnostic here. Keeping + // simple for 22b.1.) + continue; + } +}; +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: type_head_name(&inst.type_), + method: class_method.name.clone(), + }); + } +} +``` + +- [ ] **Step 7.5: Build the test fixture** + +`examples/test_22b1_missing_method.ail.json`: declares `class Eq a` with two non-default methods (`eq` and `ne`), then `instance Eq Int` that only specifies `ne`. Expected: `MissingMethod { method: "eq" }`. + +- [ ] **Step 7.6: Run the test** + +Expected: PASS. + +- [ ] **Step 7.7: Commit** + +```bash +git add crates/ailang-core/src/workspace.rs examples/test_22b1_missing_method.ail.json +git commit -m "iter 22b.1.6: method-completeness check" +``` + +--- + +### Task 8: Pre-commit verification — full test suite + bench gates + +Per CLAUDE.md, an iter close requires the test suite passes and bench gates are in defensible state. 22b.1 is schema + workspace-load only — no codegen, no runtime, no typecheck arms touched. The bench gates should not move; if they do, that's a real signal. + +- [ ] **Step 8.1: Run full test suite** + +Run: `cargo test` + +Expected: 288 passed (existing) + 7 new (22b.1.{empty registry, hash-stability x2, orphan, orphan-positive, duplicate, missing-method}) = 295. 0 failed. 3 ignored (existing). + +If FAIL: investigate before committing further. Likely cause: a `match Def::*` site in the workspace was missed in Task 2. + +- [ ] **Step 8.2: Run runtime bench gate** + +Run: `bench/check.py` + +Expected: exit 0. 63 metrics, all stable. If any move: investigate — 22b.1 should not touch any hot path. A moved metric likely means the test fixtures triggered something in the build pipeline that shouldn't be there. + +- [ ] **Step 8.3: Run compile-time bench gate** + +Run: `bench/compile_check.py` + +Expected: exit 0. 24 metrics, all stable. The bench corpus should not have grown — fixtures live under `examples/test_22b1_*`, which the compile-bench corpus does not include. + +- [ ] **Step 8.4: Run cross-language bench gate** + +Run: `bench/cross_lang.py` + +Expected: exit 0. 25 metrics, all stable. + +- [ ] **Step 8.5: Final commit (JOURNAL update)** + +Add a JOURNAL entry for 22b.1's close. Pattern follows the 22a entry from commit `f6cb900`: + +```bash +# Edit docs/JOURNAL.md to add a new section dated today, summarising: +# - what 22b.1 shipped (schema + registry + 3 coherence checks) +# - test count change (288 → 295) +# - bench gates green +# - JOURNAL queue updated: 22b.1 closed, 22b.2 next dispatch +git add docs/JOURNAL.md +git commit -m "iter 22b.1: typeclass schema floor + workspace registry — closed" +``` + +--- + +## Self-review notes + +**Spec coverage:** Every requirement of Decision 11 §"Form-A schema" subsection on ClassDef/InstanceDef shape is covered (Task 1). The three coherence checks called out in §"Resolution and monomorphisation" — coherence, uniqueness, completeness — map to Tasks 5, 6, 7. The `OrphanInstance`, `DuplicateInstance`, and `MissingMethod` diagnostic categories from §"Diagnostic categories — Workspace-load (registry-build) diagnostics" are all wired. + +Deferred to 22b.2: `OverridingNonExistentMethod` (instance specifies method not in class), `MethodNameCollision`, and the schema-time diagnostics `KindMismatch`, `InvalidSuperclassParam`, `ConstraintReferencesUnboundTypeVar`. These need the typechecker to be class-aware, which 22b.1 deliberately does not do. + +**Placeholder scan:** No "TBD" / "TODO" remains in the plan; the only "TODO comments in code" are in Task 2's downstream-match-arms, where they are deliberate markers pointing at later sub-iters. They are not plan placeholders. + +**Type consistency:** `ClassDef.param: String` (single, not `Vec`) consistent across Task 1 and the coherence-check `type_head_name` helper in Task 5. `InstanceDef.type_: Type` consistent. `Registry.entries` keyed by `(String, String)` (class-name, type-hash) consistent across Tasks 4–7. `WorkspaceLoadError` variants `OrphanInstance`/`DuplicateInstance`/`MissingMethod` referenced in tests match the variants added in Tasks 5–7. + +**Project-convention notes (deviations from the writing-plans-skill default):** + +1. **Plan location.** The skill defaults to `docs/superpowers/plans/`. AILang's project conventions place design in `docs/DESIGN.md` and decisions in `docs/JOURNAL.md`; per-iter plans are not part of the canonical-doc set. This plan is being saved to the skill's default location for the experiment, but the JOURNAL queue entry under 22a (commit `f6cb900`) already documents the same scope at a coarser level. Whether plan files like this become a standing artifact is an orchestrator decision, not a 22b.1 task. + +2. **Commit cadence.** The skill prescribes one commit per task. AILang's iter convention (CLAUDE.md §"Iter cycle") prefers tightly-scoped commits at iter boundaries — typically one commit per sub-iter, not one per task. This plan follows the skill's per-task-commit cadence (eight commits) for the 22b.1 sub-iter; the orchestrator may squash before merging into `main`. + +3. **TDD shape.** Strict red-green-commit per task is the skill's prescription and matches CLAUDE.md §"Bug fixes — TDD, always" for fixes. Feature iters in AILang have not historically been per-task TDD, but the discipline is compatible and produces a clean regression set. No conflict. + +4. **Implementer agent.** Per CLAUDE.md, implementer iters are delegated to `ailang-implementer`. This plan is shaped for direct human/LLM execution per the skill's expectation of zero-context implementer. If dispatched to `ailang-implementer`, the agent's mandatory reading list (which includes DESIGN.md, the schema patterns, and `examples/`) would supply project context that this plan does not need to repeat.