Files
AILang/docs/plans/0003-22b3-monomorphisation.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

1986 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Iteration 22b.3 — Monomorphisation Pass — Implementation Plan
> **Parent spec:** `docs/specs/0002-22-typeclasses.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Turn the `(method, type-hash)` residuals collected by the
22b.2 typecheck arms into synthesised `Def::Fn` entries (one per
unique pair) and rewrite class-method call sites in user bodies to
target the synthesised names. Validate end-to-end against a
synthetic class+instance fixture that compiles, runs, and produces
the expected stdout — independently of the Prelude landing in 22b.4.
**Architecture:** New module `crates/ailang-check/src/mono.rs`
exposing `pub fn monomorphise_workspace(ws: &Workspace) ->
Result<Workspace>`. Slots into `crates/ail/src/main.rs::build_to`
between `lift_letrecs` and `lower_workspace`, mirroring `lift.rs`'s
position and ownership model. The pass is fixpoint-iterative: each
round walks every fn / const body, re-runs `synth` to recover the
residual `(class, method, concrete-type)` triples, looks up
`registry.entries` for the matching `InstanceDef`, and synthesises
a fresh `Def::Fn` whose type substitutes the class param to the
concrete type and whose body comes from `InstanceMethod.body` (or
the class's `default` if the instance omits the method). After
synthesis, a parallel rewrite walk replaces every class-method
`Term::Var` whose name resolves through `Env.class_methods` with the
hash-deterministic mono symbol. The `(class, method, type-hash)`
key dedupes across modules; synthesised fns are appended to the
registry's `defining_module` of the matching instance. Class-free
workspaces flow through the pass byte-identically.
**Tech Stack:** `crates/ailang-check`, `crates/ailang-core`,
`crates/ail` — same crates as 22b.2.
---
## Files this plan creates or modifies
- **Create:** `crates/ailang-check/src/mono.rs` — workspace
monomorphisation pass (entry, naming, walker, synthesiser,
fixpoint, rewriter).
- **Modify:** `crates/ailang-check/src/lib.rs` — re-export
`mono::monomorphise_workspace`; expose helpers `mono.rs` needs
(`synth`, `Subst`, `is_fully_concrete`, `Env`, `ResidualConstraint`,
`substitute_rigids`) at `pub(crate)` if not already.
- **Modify:** `crates/ail/src/main.rs::build_to` — call
`monomorphise_workspace` after the `lift_letrecs` loop, before
`lower_workspace_with_alloc`.
- **Create:** `crates/ail/tests/typeclass_22b3.rs` — unit + e2e
tests for naming, single-fn collection, synthesis (instance body
+ default fallback), fixpoint dedupe, call rewrite, and the full
`ail run` synthetic fixture.
- **Create:** `examples/test_22b3_mono_synthetic.ail.json`
end-to-end fixture: `class Foo a where foo : (a) -> Int` +
`instance Foo Int where foo = λi.i` + `fn main = do io/print_int
(foo 5)`.
- **Modify:** `crates/ailang-surface/tests/round_trip.rs`
extend skip-list to also exclude `test_22b3_*` (surface parser
for class/instance defs still deferred to 22b.4).
---
## Cross-task invariants (do not break)
1. **Class-free workspaces are byte-identical through the pass.**
`monomorphise_workspace` on a workspace whose modules contain
no `Def::Class` and no `Def::Instance` returns a workspace whose
modules are *clone-equal* to the input (same defs in the same
order; canonical-bytes hash unchanged). Verified by Task 1's
identity test and re-asserted by Task 6 after rewrite lands.
2. **`CheckedModule.symbols` is never re-derived.** Same rule as
`lift_letrecs`. Synthesised FnDefs do NOT enter the canonical-
bytes symbol table — they are post-typecheck artefacts and
would break `ail diff` and `ail manifest` if hashed.
3. **The pass runs *after* `check_workspace` succeeds and *after*
`lift_letrecs`.** `mono.rs` re-uses `synth` for residual
recovery; that is sound because the typechecker has already
rejected ill-typed bodies, so synth on those bodies cannot fail
for reasons other than internal-error.
4. **Mono-symbol names are hash-deterministic.** Same workspace →
same names across runs and across hosts. Achieved by funnelling
compound types through `ailang_core::canonical::type_hash` and
primitive types through a fixed surface-form table.
5. **Fixpoint terminates.** Each round adds at least one new
`(class, method, type-hash)` entry to the worklist or it exits.
Already-synthesised entries are not re-collected.
---
## Task 1: Mono module skeleton + identity wiring
**Goal:** A no-op `monomorphise_workspace` plumbed into the build
pipeline. Class-free workspaces flow through unchanged.
**Files:**
- Create: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ailang-check/src/lib.rs`
- Modify: `crates/ail/src/main.rs` (within `fn build_to`)
- Create: `crates/ail/tests/typeclass_22b3.rs`
### Steps
- [ ] **Step 1.1 — RED test: identity on a class-free workspace.**
Create `crates/ail/tests/typeclass_22b3.rs` with this test:
```rust
//! Iter 22b.3: monomorphisation pass tests.
//!
//! Co-located with `typeclass_22b2.rs` so the typeclass-feature
//! coverage is browsable in one directory. Tests use the same
//! `examples_dir()` pattern — `cargo test`-cwd is the crate dir,
//! the fixtures live in `<repo>/examples/`.
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
// crates/ail/ → repo root → examples/
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
d.pop();
d.pop();
d.join("examples")
}
#[test]
fn monomorphise_workspace_is_identity_on_class_free_workspace() {
// Pick any class-free fixture; `examples/hello.ail.json` is
// the canonical class-free smoke test.
let entry = examples_dir().join("hello.ail.json");
let ws = ailang_core::load_workspace(&entry).expect("load");
let diags = ailang_check::check_workspace(&ws);
assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags);
let ws = ailang_check::monomorphise_workspace(&ws)
.expect("mono on class-free workspace");
// Module set unchanged.
let before = ailang_core::load_workspace(&examples_dir().join("hello.ail.json"))
.expect("re-load");
assert_eq!(
ws.modules.keys().collect::<Vec<_>>(),
before.modules.keys().collect::<Vec<_>>(),
"module set must be identical"
);
// Per-module def list unchanged (compare by canonical-JSON hash).
for (mname, m) in &ws.modules {
let before_m = &before.modules[mname];
let h_after = ailang_core::hash::module_hash(m);
let h_before = ailang_core::hash::module_hash(before_m);
assert_eq!(
h_after, h_before,
"class-free module `{}` must hash-identical through mono",
mname
);
}
}
```
Run: `cargo test -p ail --test typeclass_22b3
monomorphise_workspace_is_identity_on_class_free_workspace`
Expected: FAIL — `monomorphise_workspace` does not yet exist.
- [ ] **Step 1.2 — Create `crates/ailang-check/src/mono.rs`.**
```rust
//! Iter 22b.3: workspace monomorphisation pass.
//!
//! Slots into the build pipeline after [`crate::lift_letrecs`] and
//! before `ailang_codegen::lower_workspace_with_alloc`. The pass
//! consumes the workspace's typeclass [`Registry`] and the per-
//! module class-method tables (already populated by
//! [`crate::check_in_workspace`]) and produces a workspace with:
//!
//! 1. Synthesised [`Def::Fn`] entries — one per unique
//! `(class, method, type-hash)` triple observed at any
//! class-method call site — appended to the [`Registry`]'s
//! `defining_module` for that instance.
//! 2. Rewritten call sites — every `Term::Var { name }` whose
//! `name` resolves through [`Env::class_methods`] is replaced
//! by the corresponding mono-symbol name (qualified with the
//! instance's `defining_module` iff that module differs from
//! the calling module).
//!
//! Pre-existing `Def::Class` and `Def::Instance` entries are
//! preserved verbatim — codegen ignores them, but downstream
//! tooling (e.g. `ail describe`) may still consult them.
//!
//! Class-free workspaces are byte-identical through the pass:
//! the early-out check at the top of [`monomorphise_workspace`]
//! returns the input clone untouched.
//!
//! ## Symbol-hashing invariant
//!
//! Synthesised FnDefs are post-typecheck artefacts. They do NOT
//! enter `CheckedModule.symbols` (built from the original module
//! at typecheck time and used by `ail diff` / `ail manifest`).
//! Same convention as [`crate::lift_letrecs`].
use ailang_core::ast::{Def, FnDef, Module, Term, Type};
use ailang_core::workspace::{Registry, Workspace};
use crate::Result;
/// Iter 22b.3: workspace-wide monomorphisation pass entry. See the
/// module-level doc for the architecture and contract.
///
/// Pre-condition: `ws` has been typechecked (`check_workspace(ws)`
/// returned no errors) and lifted (`lift_letrecs` per module). The
/// pass does not perform new type checking — it queries types via
/// `synth` on already-typechecked bodies.
pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> {
// Fast path — no class / instance defs anywhere → nothing to do.
// The pass also has no targets when there are class defs but no
// instance defs (no callable methods at concrete types), but
// the body walks would still be a wasted traversal; the
// class-free check is the cheap way to opt out.
if !workspace_has_typeclasses(ws) {
return Ok(ws.clone());
}
// Skeleton in Task 1: even with classes present, return the
// workspace clone unchanged. Subsequent tasks fill in collection,
// synthesis, and rewriting.
Ok(ws.clone())
}
/// Iter 22b.3: returns `true` iff any module in `ws` declares at
/// least one [`Def::Class`] or [`Def::Instance`]. Cheap workspace
/// scan; the early-out keeps class-free workspaces byte-identical
/// through the pass.
fn workspace_has_typeclasses(ws: &Workspace) -> bool {
ws.modules.values().any(|m| {
m.defs.iter().any(|d| matches!(d, Def::Class(_) | Def::Instance(_)))
})
}
```
- [ ] **Step 1.3 — Re-export from `crates/ailang-check/src/lib.rs`.**
Add near the existing `pub use lift::lift_letrecs;` line:
```rust
mod mono;
pub use mono::monomorphise_workspace;
```
- [ ] **Step 1.4 — Wire into `crates/ail/src/main.rs::build_to`.**
Locate the existing block (around `20142034`) that runs
`lift_letrecs` per module and rebuilds `ws`. Immediately after
the `let ws = ailang_core::Workspace { ... };` rebind that
follows the lift loop, insert:
```rust
// Iter 22b.3: monomorphisation pass. After `lift_letrecs` and
// before codegen, every (class-method, concrete-type) call-site
// pair becomes a synthesised top-level fn; user call sites are
// rewritten to target those names. Class-free workspaces flow
// through bit-identically; see `ailang_check::monomorphise_workspace`.
let ws = ailang_check::monomorphise_workspace(&ws)
.map_err(|e| anyhow::anyhow!("monomorphise_workspace: {e}"))?;
```
- [ ] **Step 1.5 — Run RED test → GREEN.**
Run: `cargo test -p ail --test typeclass_22b3
monomorphise_workspace_is_identity_on_class_free_workspace`
Expected: PASS.
- [ ] **Step 1.6 — Build the workspace.**
Run: `cargo build --workspace`
Expected: clean build, no warnings.
- [ ] **Step 1.7 — Commit.**
```bash
git add crates/ailang-check/src/mono.rs \
crates/ailang-check/src/lib.rs \
crates/ail/src/main.rs \
crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 22b.3.1: mono pass skeleton — identity for class-free workspaces"
```
---
## Task 2: Mono-symbol naming function
**Goal:** Hash-deterministic name function. Primitives produce
human-readable forms (`show#Int`, `eq#Bool`); compound types fall
to `<method>#<8-hex-prefix-of-type-hash>`.
**Files:**
- Modify: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ail/tests/typeclass_22b3.rs`
### Steps
- [ ] **Step 2.1 — RED test: primitive-type names.**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
use ailang_core::ast::Type;
#[test]
fn mono_symbol_primitive_types_use_surface_form() {
// Primitive-type forms must be human-readable in diagnostics
// and ABI symbols. Per the spec's mono-symbol naming guidance:
// <method>#<surface-name> for primitives.
let int_ty = Type::int();
let bool_ty = Type::bool_();
let str_ty = Type::str_();
let unit_ty = Type::unit();
assert_eq!(ailang_check::mono::mono_symbol("show", &int_ty), "show#Int");
assert_eq!(ailang_check::mono::mono_symbol("eq", &bool_ty), "eq#Bool");
assert_eq!(ailang_check::mono::mono_symbol("show", &str_ty), "show#Str");
assert_eq!(ailang_check::mono::mono_symbol("foo", &unit_ty), "foo#Unit");
}
#[test]
fn mono_symbol_compound_type_uses_hash_suffix() {
// Compound types fall through to canonical type_hash with an
// 8-hex-char prefix. Stability matters more than legibility
// here — same input must produce the same name across runs.
let pair_ty = Type::Con {
name: "Pair".into(),
args: vec![Type::int(), Type::bool_()],
};
let name = ailang_check::mono::mono_symbol("show", &pair_ty);
// Format: `show#<8-hex-chars>`.
assert!(name.starts_with("show#"), "name = {name}");
let suffix = &name["show#".len()..];
assert_eq!(suffix.len(), 8, "compound suffix is 8 hex chars: {suffix}");
assert!(
suffix.chars().all(|c| c.is_ascii_hexdigit()),
"compound suffix must be hex: {suffix}"
);
// Stability: a second call with the same type produces the same name.
let again = ailang_check::mono::mono_symbol("show", &pair_ty);
assert_eq!(name, again, "mono_symbol must be deterministic");
}
```
To make `ailang_check::mono::*` reachable from the integration
test, the `mod mono;` declaration in `lib.rs` must be
`pub mod mono;` (or the symbol re-exported individually at the
crate root). Use `pub mod mono;` for this iter — the module's
internals are still gated by `pub(crate)`-or-narrower; only
`mono_symbol` and `monomorphise_workspace` are `pub`.
Adjust `crates/ailang-check/src/lib.rs`:
```rust
// Replace the line from Step 1.3:
pub mod mono;
pub use mono::monomorphise_workspace;
```
Run: `cargo test -p ail --test typeclass_22b3 mono_symbol_`
Expected: FAIL — `mono_symbol` not yet defined.
- [ ] **Step 2.2 — Implement `mono_symbol` in `mono.rs`.**
Append to `crates/ailang-check/src/mono.rs`:
```rust
/// Iter 22b.3: deterministic mono-symbol name for a `(method,
/// type)` pair. Primitive types (`Int`, `Bool`, `Str`, `Float`,
/// `Unit`) produce `<method>#<surface-name>` for diagnostic and
/// ABI legibility. All other types — parameterised cons,
/// user-defined ADTs, function types — fall to
/// `<method>#<8-hex-prefix-of-canonical-type-hash>`. The hash
/// route ensures uniqueness without requiring a flattened
/// surface form for arbitrarily nested types.
///
/// Determinism: `ailang_core::canonical::type_hash` is the same
/// function `workspace::build_registry` uses to key
/// [`Registry::entries`], so a registry-key match implies a
/// `mono_symbol` match.
pub fn mono_symbol(method: &str, ty: &Type) -> String {
if let Some(prim) = primitive_surface_name(ty) {
return format!("{method}#{prim}");
}
let full_hash = ailang_core::canonical::type_hash(ty);
// 8-hex prefix is enough for low-collision keying across the
// workspace; the full hash remains in the registry for
// disambiguation should one ever be needed.
format!("{method}#{}", &full_hash[..8])
}
/// Iter 22b.3: returns the surface name iff `ty` is a zero-arity
/// primitive `Type::Con`. Used by [`mono_symbol`] to gate the
/// human-readable form. The match is intentionally narrow:
/// `Int<args>` (which is malformed but parser-accepting) is
/// treated as compound, so it falls to the hash form.
fn primitive_surface_name(ty: &Type) -> Option<&'static str> {
match ty {
Type::Con { name, args } if args.is_empty() => match name.as_str() {
"Int" => Some("Int"),
"Bool" => Some("Bool"),
"Str" => Some("Str"),
"Float" => Some("Float"),
"Unit" => Some("Unit"),
_ => None,
},
_ => None,
}
}
```
- [ ] **Step 2.3 — Run RED tests → GREEN.**
Run: `cargo test -p ail --test typeclass_22b3 mono_symbol_`
Expected: PASS (both tests).
- [ ] **Step 2.4 — Commit.**
```bash
git add crates/ailang-check/src/mono.rs \
crates/ailang-check/src/lib.rs \
crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 22b.3.2: mono_symbol — primitive surface forms + hash for compound"
```
---
## Task 3: Per-fn target collection (synth-replay)
**Goal:** Given a `FnDef` and an `Env` populated with the workspace
class-method table and registry, produce a `Vec<MonoTarget>` of all
fully-concrete `(class, method, type)` triples observed at
class-method call sites in the body.
**Files:**
- Modify: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ailang-check/src/lib.rs` (loosen visibility on
`synth`, `Subst`, `is_fully_concrete`, `ResidualConstraint`,
`substitute_rigids` if not already `pub(crate)`).
- Modify: `crates/ail/tests/typeclass_22b3.rs`
### Steps
- [ ] **Step 3.1 — RED test: single fn, single concrete call site.**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
use ailang_check::mono::{collect_mono_targets, MonoTarget};
/// Single-fn module: `fn user = show 5`. Class `Show a`, instance
/// `Show Int` declared in same module. Expected: exactly one
/// MonoTarget — `(Show, show, Int)`.
#[test]
fn collect_mono_targets_single_concrete_call_site() {
let entry = examples_dir().join("test_22b2_instance_present.ail.json");
let ws = ailang_core::load_workspace(&entry).expect("load");
let diags = ailang_check::check_workspace(&ws);
assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags);
// Build the workspace-wide env (class_methods, registry, ...)
// exactly as `check_in_workspace` does. The mono pass exposes
// this via `build_workspace_env`.
let env = ailang_check::mono::build_workspace_env(&ws);
// The fixture has one user fn — `main`. Locate it.
let main_module = ws.modules.get("test_22b2_instance_present").expect("module");
let main_fn = main_module
.defs
.iter()
.find_map(|d| match d {
ailang_core::ast::Def::Fn(f) if f.name == "main" => Some(f),
_ => None,
})
.expect("main fn");
let targets = collect_mono_targets(main_fn, "test_22b2_instance_present", &env)
.expect("collect");
assert_eq!(targets.len(), 1, "one target expected: {:?}", targets);
let t = &targets[0];
assert_eq!(t.class, "Show");
assert_eq!(t.method, "show");
assert!(
matches!(&t.type_, ailang_core::ast::Type::Con { name, args } if name == "Int" && args.is_empty()),
"type must be Int, got {:?}",
t.type_
);
assert_eq!(t.defining_module, "test_22b2_instance_present");
}
```
Run: `cargo test -p ail --test typeclass_22b3
collect_mono_targets_single_concrete_call_site`
Expected: FAIL — `MonoTarget`, `collect_mono_targets`, and
`build_workspace_env` not yet defined.
- [ ] **Step 3.2 — Loosen visibility on `lib.rs`-internal helpers.**
Open `crates/ailang-check/src/lib.rs`. Confirm or change to
`pub(crate)` if not already:
- `pub(crate) fn synth(...)` — already `pub(crate)`.
- `pub(crate) struct ResidualConstraint` — already `pub(crate)`.
- `is_fully_concrete` — currently private; change to
`pub(crate) fn is_fully_concrete(t: &Type) -> bool`.
- `substitute_rigids` — currently private; change to
`pub(crate) fn substitute_rigids(...) -> Type`.
- `Subst` (struct) — already accessible via `crate::Subst` from
`lift.rs`; verify that `ResidualConstraint`, `Subst`, and the
`synth` signature all permit a sibling-module call from
`mono.rs`.
No semantic changes; just visibility.
- [ ] **Step 3.3 — Implement `MonoTarget` and the env builder in `mono.rs`.**
Append to `crates/ailang-check/src/mono.rs`:
```rust
use ailang_core::ast::FnDef as AstFnDef;
use ailang_core::workspace::Workspace;
use indexmap::IndexMap;
use std::collections::{BTreeMap, BTreeSet};
/// Iter 22b.3: one synthesisation request — a fully-concrete
/// `(class, method, type)` triple observed at a class-method call
/// site, plus the registry's `defining_module` for the matching
/// `InstanceDef` (the synthesised fn lives there). Equality of
/// targets is compared via [`mono_target_key`] — `(class,
/// method, type-hash)`.
#[derive(Debug, Clone)]
pub struct MonoTarget {
pub class: String,
pub method: String,
pub type_: Type,
pub defining_module: String,
}
/// Iter 22b.3: dedup key for a [`MonoTarget`] — the same triple
/// `Registry::entries` uses for instance lookup, plus the method
/// name. Two targets with the same key produce the same
/// synthesised symbol and the same body.
pub(crate) fn mono_target_key(t: &MonoTarget) -> (String, String, String) {
(
t.class.clone(),
t.method.clone(),
ailang_core::canonical::type_hash(&t.type_),
)
}
/// Iter 22b.3: build the workspace-wide [`crate::Env`] used by
/// the mono pass to re-run [`crate::synth`] on each fn body.
/// Mirrors `check_in_workspace`'s setup: builtins + workspace
/// registry + per-module globals + class-method table +
/// superclass map. Cross-module type defs and consts are NOT
/// installed here — `synth` for mono only needs to (a) recognise
/// class-method names and (b) unify call-site arg types with
/// declared param types. Module-resident fns are still needed
/// for non-class-method calls inside instance bodies.
pub fn build_workspace_env(ws: &Workspace) -> crate::Env {
let mut env = crate::Env::default();
crate::builtins::install(&mut env);
env.workspace_registry = ws.registry.clone();
// Per-module globals + class method table + superclass map.
// The construction order matches `check_in_workspace` so the
// env shape is interchangeable.
let module_globals_index = crate::build_module_globals(ws);
env.module_globals = module_globals_index
.iter()
.map(|(mname, g)| (mname.clone(), g.fns.clone()))
.collect();
env.module_types = ws
.modules
.iter()
.map(|(mname, m)| {
let tys: IndexMap<String, ailang_core::ast::TypeDef> = m
.defs
.iter()
.filter_map(|d| match d {
Def::Type(td) => Some((td.name.clone(), td.clone())),
_ => None,
})
.collect();
(mname.clone(), tys)
})
.collect();
// Workspace-wide class-method table: merge per module's
// ModuleGlobals.class_methods.
for (_mname, g) in &module_globals_index {
for (name, entry) in &g.class_methods {
env.class_methods.insert(name.clone(), entry.clone());
}
}
// Superclass map: walk every module's class defs.
for m in ws.modules.values() {
for d in &m.defs {
if let Def::Class(c) = d {
if let Some(sc) = &c.superclass {
env.class_superclasses.insert(c.name.clone(), sc.class.clone());
}
}
}
}
env
}
/// Iter 22b.3: re-run [`crate::synth`] on `f`'s body to recover
/// the per-fn residual class constraints. Filter to fully-
/// concrete residuals (the only ones eligible for monomorphisation),
/// look up each `(class, type-hash)` in the registry to recover
/// the `defining_module`, and return the list. Var-shaped or
/// metavar-shaped residuals are silently skipped — the
/// 22b.2 typecheck pass has already fired
/// `MissingConstraint`/`NoInstance` for any that should not exist
/// at this point.
pub fn collect_mono_targets(
f: &AstFnDef,
module_name: &str,
env: &crate::Env,
) -> Result<Vec<MonoTarget>> {
// Build the per-def env exactly as `check_fn` does — install
// rigid vars, set the current module, etc. This mirrors
// `crate::check_fn` minus the diagnostic emission.
let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty {
Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()),
other => (vec![], other.clone()),
};
let (param_tys, _ret_ty, _eff): (Vec<Type>, Type, Vec<String>) = match &inner_ty {
Type::Fn { params, ret, effects, .. } => {
(params.clone(), (**ret).clone(), effects.clone())
}
_ => return Ok(Vec::new()),
};
let mut env = env.clone();
for v in &rigids {
env.rigid_vars.insert(v.clone());
}
env.current_module = module_name.to_string();
let mut locals: IndexMap<String, Type> = IndexMap::new();
for (n, t) in f.params.iter().zip(param_tys.iter()) {
locals.insert(n.clone(), t.clone());
}
let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = crate::Subst::default();
let mut counter: u32 = 0;
let mut residuals: Vec<crate::ResidualConstraint> = Vec::new();
crate::synth(
&f.body,
&env,
&mut locals,
&mut effects,
&f.name,
&mut subst,
&mut counter,
&mut residuals,
)?;
// Filter residuals to fully-concrete ones; look up
// defining_module via the registry.
let mut out: Vec<MonoTarget> = Vec::new();
for r in residuals {
let r_ty = subst.apply(&r.type_);
if !crate::is_fully_concrete(&r_ty) {
continue;
}
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty));
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.
};
out.push(MonoTarget {
class: r.class.clone(),
method: r.method.clone(),
type_: r_ty,
defining_module: entry.defining_module.clone(),
});
}
Ok(out)
}
```
Note: `crate::build_module_globals` already exists at the
`lib.rs` level (called by `check_in_workspace`); confirm its
visibility is at least `pub(crate)`. If currently private,
promote it to `pub(crate)`.
- [ ] **Step 3.4 — Run RED test → GREEN.**
Run: `cargo test -p ail --test typeclass_22b3
collect_mono_targets_single_concrete_call_site`
Expected: PASS.
- [ ] **Step 3.5 — Workspace build clean.**
Run: `cargo build --workspace`
Expected: clean build.
- [ ] **Step 3.6 — Commit.**
```bash
git add crates/ailang-check/src/mono.rs \
crates/ailang-check/src/lib.rs \
crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 22b.3.3: collect_mono_targets — synth-replay residual gathering"
```
---
## Task 4: Synthesise a FnDef from a MonoTarget
**Goal:** Given a `MonoTarget`, the matching `ClassDef`, and the
matching `InstanceDef`, produce a `FnDef` whose name is
`mono_symbol(method, type_)`, whose `ty` is the class method type
with the class param substituted to `type_`, and whose `params` /
`body` come from the instance's `InstanceMethod.body` (or, if the
instance omits the method, the class's `default` body).
**Files:**
- Modify: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ail/tests/typeclass_22b3.rs`
### Steps
- [ ] **Step 4.1 — RED test: synthesise from instance body (Lam).**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
use ailang_core::ast::{ClassDef, ClassMethod, InstanceDef, InstanceMethod, Literal, Term};
use ailang_check::mono::synthesise_mono_fn;
/// Build `class Foo a where bar : (a) -> Int` + `instance Foo Int
/// where bar = λi.i` (well-typed body). synthesise_mono_fn must
/// produce `FnDef { name: "bar#Int", ty: (Int) -> Int,
/// params: ["i"], body: Var "i" }`.
#[test]
fn synthesise_mono_fn_uses_instance_body_lam() {
let class_def = ClassDef {
name: "Foo".into(),
param: "a".into(),
superclass: None,
methods: vec![ClassMethod {
name: "bar".into(),
ty: Type::Fn {
params: vec![Type::Var { name: "a".into() }],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
},
default: None,
}],
doc: None,
};
let instance = InstanceDef {
class: "Foo".into(),
type_: Type::int(),
methods: vec![InstanceMethod {
name: "bar".into(),
body: Term::Lam {
params: vec!["i".into()],
param_tys: vec![Type::Var { name: "a".into() }],
ret_ty: Box::new(Type::int()),
effects: vec![],
body: Box::new(Term::Var { name: "i".into() }),
},
}],
doc: None,
};
let target = MonoTarget {
class: "Foo".into(),
method: "bar".into(),
type_: Type::int(),
defining_module: "m".into(),
};
let f = synthesise_mono_fn(&target, &class_def, &instance).expect("synth");
assert_eq!(f.name, "bar#Int");
assert!(
matches!(
&f.ty,
Type::Fn { params, ret, .. }
if params.len() == 1
&& matches!(&params[0], Type::Con { name, args } if name == "Int" && args.is_empty())
&& matches!(ret.as_ref(), Type::Con { name, args } if name == "Int" && args.is_empty())
),
"ty = {:?}",
f.ty
);
assert_eq!(f.params, vec!["i".to_string()]);
assert!(
matches!(&f.body, Term::Var { name } if name == "i"),
"body = {:?}",
f.body
);
}
/// Class with a `default` body, instance omits the method.
/// synthesise_mono_fn must fall back to the class default.
#[test]
fn synthesise_mono_fn_falls_back_to_class_default() {
let class_def = ClassDef {
name: "Greet".into(),
param: "a".into(),
superclass: None,
methods: vec![ClassMethod {
name: "hello".into(),
ty: Type::Fn {
params: vec![Type::Var { name: "a".into() }],
ret: Box::new(Type::str_()),
effects: vec![],
param_modes: vec![],
},
default: Some(Term::Lam {
params: vec!["_x".into()],
param_tys: vec![Type::Var { name: "a".into() }],
ret_ty: Box::new(Type::str_()),
effects: vec![],
body: Box::new(Term::Lit {
lit: Literal::Str { value: "hi".into() },
}),
}),
}],
doc: None,
};
// Instance carries no `hello` body → must fall back to default.
let instance = InstanceDef {
class: "Greet".into(),
type_: Type::int(),
methods: vec![],
doc: None,
};
let target = MonoTarget {
class: "Greet".into(),
method: "hello".into(),
type_: Type::int(),
defining_module: "m".into(),
};
let f = synthesise_mono_fn(&target, &class_def, &instance).expect("synth");
assert_eq!(f.name, "hello#Int");
assert_eq!(f.params, vec!["_x".to_string()]);
assert!(matches!(&f.body, Term::Lit { .. }), "body = {:?}", f.body);
}
```
(Note: `Type::Fn` literal construction must include
`param_modes: vec![]` — Decision 10 default for non-`borrow`
params. Confirm field name in `crates/ailang-core/src/ast.rs`.)
Run: `cargo test -p ail --test typeclass_22b3 synthesise_mono_fn_`
Expected: FAIL — `synthesise_mono_fn` not defined.
- [ ] **Step 4.2 — Implement `synthesise_mono_fn` in `mono.rs`.**
Append to `crates/ailang-check/src/mono.rs`:
```rust
use ailang_core::ast::{ClassDef, InstanceDef};
/// Iter 22b.3: produce a `FnDef` for a single (target, class,
/// instance) triple. The synthesised fn:
///
/// 1. has name `mono_symbol(target.method, target.type_)`,
/// 2. has type = the class's method type with class param
/// substituted to `target.type_` (rigid-var substitution
/// via [`crate::substitute_rigids`]); the result is a plain
/// `Type::Fn` with no `Forall`,
/// 3. has params + body taken from the instance's matching
/// `InstanceMethod.body` if present and Lam-shaped; from the
/// class's `default` body if the instance omits the method;
/// or directly from the body if the method has no params
/// (zero-arg method types).
///
/// Errors:
///
/// - The instance omits the method AND the class has no
/// `default` → unreachable in practice because
/// `workspace::build_registry`'s `MissingMethod` check fires
/// at load. Treat as `Internal`.
/// - The method type is `(args) -> ret` with `args` non-empty,
/// but the resolved body is not `Term::Lam` → schema-shape
/// mismatch. Treat as `Internal`. (Future: surface a
/// user-facing diagnostic; out of scope for 22b.3.)
pub fn synthesise_mono_fn(
target: &MonoTarget,
class_def: &ClassDef,
instance: &InstanceDef,
) -> Result<AstFnDef> {
// Locate the class method declaration.
let class_method = class_def
.methods
.iter()
.find(|m| m.name == target.method)
.ok_or_else(|| {
crate::CheckError::Internal(format!(
"synthesise_mono_fn: class `{}` has no method `{}`",
class_def.name, target.method
))
})?;
// Build the substitution `param := target.type_` and apply
// it to the method type. The result is a concrete `Type::Fn`
// (no `Forall`).
let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
mapping.insert(class_def.param.clone(), target.type_.clone());
let concrete_method_ty = crate::substitute_rigids(&class_method.ty, &mapping);
// Resolve the body — instance override first, then class default.
let body_term: Term = match instance.methods.iter().find(|im| im.name == target.method) {
Some(im) => im.body.clone(),
None => class_method.default.clone().ok_or_else(|| {
crate::CheckError::Internal(format!(
"synthesise_mono_fn: instance `{} {}` omits method `{}` and class has no default \
(registry-build should have rejected this)",
target.class,
ailang_core::pretty::type_to_string(&target.type_),
target.method,
))
})?,
};
// Decide the (params, body) shape based on whether the method
// type takes positional args.
let method_has_params = matches!(
&class_method.ty,
Type::Fn { params, .. } if !params.is_empty()
);
let (params, body): (Vec<String>, Term) = if method_has_params {
// Method has positional params — body must be a Lam.
match body_term {
Term::Lam { params, body, .. } => (params, *body),
other => {
return Err(crate::CheckError::Internal(format!(
"synthesise_mono_fn: method `{}` has positional params but body is not a Lam: {:?}",
target.method, other
)));
}
}
} else {
// Zero-arg method — body is the value directly.
(Vec::new(), body_term)
};
Ok(AstFnDef {
name: mono_symbol(&target.method, &target.type_),
ty: concrete_method_ty,
params,
body,
doc: None,
suppress: Vec::new(),
})
}
```
- [ ] **Step 4.3 — Run RED tests → GREEN.**
Run: `cargo test -p ail --test typeclass_22b3 synthesise_mono_fn_`
Expected: PASS (both tests).
- [ ] **Step 4.4 — Commit.**
```bash
git add crates/ailang-check/src/mono.rs \
crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 22b.3.4: synthesise_mono_fn — instance body + class default fallback"
```
---
## Task 5: Workspace fixpoint loop
**Goal:** Iterate over every fn / const body in the workspace,
collect targets, dedupe via `mono_target_key`, synthesise FnDefs,
and append them to the registry's `defining_module`. Loop until no
new targets appear (so synthesised bodies' own class-method calls
are also covered).
**Files:**
- Modify: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ail/tests/typeclass_22b3.rs`
### Steps
- [ ] **Step 5.1 — RED test: dedup across two call sites.**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
/// Two call sites of `show 5` and `show 7` (same type Int) → the
/// pass synthesises exactly ONE `show#Int` FnDef in the
/// instance's defining module. Verifies the dedup contract.
#[test]
fn fixpoint_dedupes_repeated_calls_at_same_type() {
let entry = examples_dir().join("test_22b3_dup_call_sites.ail.json");
let ws = ailang_core::load_workspace(&entry).expect("load");
let diags = ailang_check::check_workspace(&ws);
assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags);
let ws = ailang_check::monomorphise_workspace(&ws).expect("mono");
// Count `show#Int` fns workspace-wide.
let mut count = 0usize;
for m in ws.modules.values() {
for d in &m.defs {
if let ailang_core::ast::Def::Fn(f) = d {
if f.name == "show#Int" {
count += 1;
}
}
}
}
assert_eq!(count, 1, "exactly one show#Int across the workspace");
}
```
Create the fixture
`examples/test_22b3_dup_call_sites.ail.json`:
```json
{
"schema": "ailang/v0",
"name": "test_22b3_dup_call_sites",
"imports": [],
"defs": [
{
"kind": "class", "name": "Show", "param": "a",
"methods": [{
"name": "show",
"type": {
"k": "fn", "params": [{ "k": "var", "name": "a" }],
"ret": { "k": "con", "name": "Str" }, "effects": []
}
}]
},
{
"kind": "instance",
"class": "Show",
"type": { "k": "con", "name": "Int" },
"methods": [{
"name": "show",
"body": {
"t": "lam",
"params": ["x"],
"paramTypes": [{ "k": "var", "name": "a" }],
"retType": { "k": "con", "name": "Str" },
"body": { "t": "lit", "lit": { "kind": "str", "value": "n" } }
}
}]
},
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn", "params": [], "ret": { "k": "con", "name": "Str" },
"effects": []
},
"params": [],
"body": {
"t": "seq",
"lhs": {
"t": "app",
"fn": { "t": "var", "name": "show" },
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }]
},
"rhs": {
"t": "app",
"fn": { "t": "var", "name": "show" },
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 7 } }]
}
}
}
]
}
```
Run: `cargo test -p ail --test typeclass_22b3
fixpoint_dedupes_repeated_calls_at_same_type`
Expected: FAIL — pass currently returns the workspace unchanged
(Task 1 skeleton).
- [ ] **Step 5.2 — Replace the Task-1 skeleton return with the
fixpoint loop.**
Edit `crates/ailang-check/src/mono.rs::monomorphise_workspace`:
```rust
pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> {
if !workspace_has_typeclasses(ws) {
return Ok(ws.clone());
}
// Take ownership of a mutable workspace clone; the pass
// appends synthesised defs to per-module def lists.
let mut ws_owned: Workspace = ws.clone();
let env = build_workspace_env(&ws_owned);
// Dedup set: every (class, method, type-hash) we have already
// synthesised. Once a key is here, future collection rounds
// skip it.
let mut synthesised: BTreeSet<(String, String, String)> = BTreeSet::new();
// Fixpoint: keep collecting until a round adds nothing new.
// Each round walks every fn body in every module — including
// bodies appended by the previous round, which is what makes
// the loop close on chained class-method calls (e.g.
// `Eq.ne x y = not (eq x y)`).
loop {
let new_targets = collect_targets_workspace_wide(&ws_owned, &env)?;
let new: Vec<MonoTarget> = new_targets
.into_iter()
.filter(|t| !synthesised.contains(&mono_target_key(t)))
.collect();
if new.is_empty() {
break;
}
// Synthesise each new target; append its FnDef to the
// target's `defining_module`. Mark the key as synthesised
// so the next round won't re-collect.
//
// Iter 22b.3 uses ws_owned.registry as the source of
// truth for ClassDef + InstanceDef lookups. Both come
// from the un-modified workspace registry — synthesis
// never mutates the registry.
let class_index = build_class_index(&ws_owned);
for t in &new {
let key = mono_target_key(t);
let registry_key = (t.class.clone(), ailang_core::canonical::type_hash(&t.type_));
let entry = ws_owned
.registry
.entries
.get(&registry_key)
.ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: target `{} {}` has no registry entry",
t.class,
ailang_core::pretty::type_to_string(&t.type_),
))
})?;
let class_def = class_index.get(&t.class).ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: class `{}` not found", t.class
))
})?;
let f = synthesise_mono_fn(t, class_def, &entry.instance)?;
let target_module = ws_owned
.modules
.get_mut(&t.defining_module)
.ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: defining module `{}` missing",
t.defining_module
))
})?;
target_module.defs.push(Def::Fn(f));
synthesised.insert(key);
}
}
// Task 6 will splice the call-site rewrite here.
Ok(ws_owned)
}
/// Iter 22b.3: walk every fn / const body in the workspace,
/// returning the union of [`collect_mono_targets`] outputs. Const
/// bodies are wrapped in a synthetic zero-arg `FnDef` for the
/// collection call — the residual gathering only depends on body
/// shape, so the wrapping is harmless.
fn collect_targets_workspace_wide(
ws: &Workspace,
env: &crate::Env,
) -> Result<Vec<MonoTarget>> {
let mut out: Vec<MonoTarget> = Vec::new();
for (mname, m) in &ws.modules {
for d in &m.defs {
match d {
Def::Fn(f) => {
out.extend(collect_mono_targets(f, mname, env)?);
}
Def::Const(c) => {
let pseudo = AstFnDef {
name: c.name.clone(),
ty: c.ty.clone(),
params: Vec::new(),
body: c.value.clone(),
doc: None,
suppress: Vec::new(),
};
out.extend(collect_mono_targets(&pseudo, mname, env)?);
}
_ => {}
}
}
}
Ok(out)
}
/// Iter 22b.3: workspace-wide `class-name -> ClassDef` index.
/// Used by the fixpoint to look up the matching class definition
/// when synthesising a fn.
fn build_class_index(ws: &Workspace) -> BTreeMap<String, ClassDef> {
let mut idx = BTreeMap::new();
for m in ws.modules.values() {
for d in &m.defs {
if let Def::Class(c) = d {
idx.insert(c.name.clone(), c.clone());
}
}
}
idx
}
```
- [ ] **Step 5.3 — Run RED test → GREEN.**
Run: `cargo test -p ail --test typeclass_22b3
fixpoint_dedupes_repeated_calls_at_same_type`
Expected: PASS.
- [ ] **Step 5.4 — Re-run all 22b.3 tests so far.**
Run: `cargo test -p ail --test typeclass_22b3`
Expected: ALL PASS (5+ tests).
Run: `cargo test -p ail --test typeclass_22b2`
Expected: ALL PASS (12 tests, no regression from 22b.2).
- [ ] **Step 5.5 — Commit.**
```bash
git add crates/ailang-check/src/mono.rs \
crates/ail/tests/typeclass_22b3.rs \
examples/test_22b3_dup_call_sites.ail.json
git commit -m "iter 22b.3.5: workspace fixpoint loop — dedup + synth-append"
```
---
## Task 6: Call-site rewrite
**Goal:** Rewrite every `Term::Var { name }` whose `name` resolves
through `Env.class_methods` to the corresponding mono-symbol name.
Same-module call sites use the unqualified name; cross-module call
sites use `<defining_module>.<mono_symbol>`. Same traversal order
as `collect_mono_targets`'s synth-replay so per-call-site type
resolution stays aligned via positional indexing.
**Files:**
- Modify: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ail/tests/typeclass_22b3.rs`
### Steps
- [ ] **Step 6.1 — RED test: same-module call site is rewritten.**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
/// After the mono pass, the body of `main` in
/// test_22b2_instance_present must reference `show#Int` instead
/// of `show`.
#[test]
fn rewrite_replaces_class_method_var_with_mono_symbol_same_module() {
let entry = examples_dir().join("test_22b2_instance_present.ail.json");
let ws = ailang_core::load_workspace(&entry).expect("load");
let diags = ailang_check::check_workspace(&ws);
assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags);
let ws = ailang_check::monomorphise_workspace(&ws).expect("mono");
let main_module = ws.modules.get("test_22b2_instance_present").unwrap();
let main_fn = main_module
.defs
.iter()
.find_map(|d| match d {
ailang_core::ast::Def::Fn(f) if f.name == "main" => Some(f),
_ => None,
})
.expect("main");
// The body is `App { fn: Var "show", args: [Lit 42] }`. After
// rewrite, the callee Var must be `show#Int`.
let callee_name: &str = match &main_fn.body {
ailang_core::ast::Term::App { callee, .. } => match callee.as_ref() {
ailang_core::ast::Term::Var { name } => name.as_str(),
other => panic!("callee is not Var: {:?}", other),
},
other => panic!("body is not App: {:?}", other),
};
assert_eq!(callee_name, "show#Int");
}
```
Run: `cargo test -p ail --test typeclass_22b3
rewrite_replaces_class_method_var_with_mono_symbol_same_module`
Expected: FAIL — call site still says `show`.
- [ ] **Step 6.2 — Implement the rewrite walk.**
Append to `crates/ailang-check/src/mono.rs`:
```rust
use ailang_core::ast::Arm;
/// Iter 22b.3: rewrite every class-method call site in `body` to
/// the corresponding mono symbol, using `class_method_types` —
/// the per-call-site resolved types collected by a parallel
/// synth-replay run on the same body.
///
/// The walker increments a positional counter at each
/// class-method-named `Term::Var` it encounters. The counter must
/// match the order in which `synth` pushes residuals (pre-order
/// AST walk; child evaluation order matches the `match` arms in
/// `crate::synth`). When this invariant holds, `class_method_types[idx]`
/// is the `Type` resolved at the i-th class-method call site.
///
/// `caller_module`: name of the module enclosing this body. If
/// the target's `defining_module` differs, the rewritten name is
/// qualified `<defining_module>.<mono_symbol>` (cross-module
/// resolution path); otherwise unqualified.
fn rewrite_class_method_calls(
body: &mut Term,
class_methods: &BTreeMap<String, crate::ClassMethodEntry>,
registry: &Registry,
caller_module: &str,
ordered_targets: &[MonoTarget],
cursor: &mut usize,
) {
match body {
Term::Var { name } => {
if class_methods.contains_key(name) {
// i-th class-method site → ordered_targets[i] is the
// resolved triple. (collect_mono_targets filters
// non-concrete residuals, but the rewrite walk must
// count *every* class-method occurrence; we recover
// the alignment via [`reorder_targets_by_callsite`]
// before this function runs.)
if let Some(t) = ordered_targets.get(*cursor) {
let sym = mono_symbol(&t.method, &t.type_);
let new_name = if t.defining_module == caller_module {
sym
} else {
format!("{}.{}", t.defining_module, sym)
};
*name = new_name;
}
*cursor += 1;
}
}
Term::App { callee, args, .. } => {
rewrite_class_method_calls(callee, class_methods, registry, caller_module, ordered_targets, cursor);
for a in args {
rewrite_class_method_calls(a, class_methods, registry, caller_module, ordered_targets, cursor);
}
}
Term::Let { value, body, .. } => {
rewrite_class_method_calls(value, class_methods, registry, caller_module, ordered_targets, cursor);
rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::LetRec { body, in_term, .. } => {
rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor);
rewrite_class_method_calls(in_term, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::If { cond, then, else_ } => {
rewrite_class_method_calls(cond, class_methods, registry, caller_module, ordered_targets, cursor);
rewrite_class_method_calls(then, class_methods, registry, caller_module, ordered_targets, cursor);
rewrite_class_method_calls(else_, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::Do { args, .. } => {
for a in args {
rewrite_class_method_calls(a, class_methods, registry, caller_module, ordered_targets, cursor);
}
}
Term::Ctor { args, .. } => {
for a in args {
rewrite_class_method_calls(a, class_methods, registry, caller_module, ordered_targets, cursor);
}
}
Term::Match { scrutinee, arms } => {
rewrite_class_method_calls(scrutinee, class_methods, registry, caller_module, ordered_targets, cursor);
for Arm { body, .. } in arms {
rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor);
}
}
Term::Lam { body, .. } => {
rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::Seq { lhs, rhs } => {
rewrite_class_method_calls(lhs, class_methods, registry, caller_module, ordered_targets, cursor);
rewrite_class_method_calls(rhs, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::Clone { value } => {
rewrite_class_method_calls(value, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::ReuseAs { source, body } => {
rewrite_class_method_calls(source, class_methods, registry, caller_module, ordered_targets, cursor);
rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor);
}
Term::Lit { .. } => {}
}
}
```
Note: the `class_method_types` argument named in the doc maps to
`ordered_targets` — Vec<MonoTarget> in *AST-traversal order* (one
entry per class-method `Term::Var` occurrence, including
duplicates). To produce that, change `collect_mono_targets` to
return targets in traversal order — which it already does
(residuals are appended by `synth` in pre-order). The filter
`is_fully_concrete` in `collect_mono_targets` would break the
alignment, so add a parallel collector that returns ALL residuals
(concrete or not) in order:
Add to `mono.rs`:
```rust
/// 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
/// as `None`, preserving one-entry-per-callsite alignment.
pub(crate) fn collect_residuals_ordered(
f: &AstFnDef,
module_name: &str,
env: &crate::Env,
) -> Result<Vec<Option<MonoTarget>>> {
// Same setup as `collect_mono_targets`, but the filter step
// returns `None` for non-concrete residuals instead of
// dropping them.
let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty {
Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()),
other => (vec![], other.clone()),
};
let param_tys: Vec<Type> = match &inner_ty {
Type::Fn { params, .. } => params.clone(),
_ => return Ok(Vec::new()),
};
let mut env = env.clone();
for v in &rigids {
env.rigid_vars.insert(v.clone());
}
env.current_module = module_name.to_string();
let mut locals: IndexMap<String, Type> = IndexMap::new();
for (n, t) in f.params.iter().zip(param_tys.iter()) {
locals.insert(n.clone(), t.clone());
}
let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = crate::Subst::default();
let mut counter: u32 = 0;
let mut residuals: Vec<crate::ResidualConstraint> = Vec::new();
crate::synth(
&f.body,
&env,
&mut locals,
&mut effects,
&f.name,
&mut subst,
&mut counter,
&mut residuals,
)?;
let mut out: Vec<Option<MonoTarget>> = Vec::new();
for r in residuals {
let r_ty = subst.apply(&r.type_);
if !crate::is_fully_concrete(&r_ty) {
out.push(None);
continue;
}
let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty));
let entry = match env.workspace_registry.entries.get(&key) {
Some(e) => e,
None => {
out.push(None);
continue;
}
};
out.push(Some(MonoTarget {
class: r.class.clone(),
method: r.method.clone(),
type_: r_ty,
defining_module: entry.defining_module.clone(),
}));
}
Ok(out)
}
```
Then splice the rewrite phase into `monomorphise_workspace`
*after* the fixpoint loop:
```rust
// Replace `Ok(ws_owned)` at the end of the fn with:
// Phase 3: rewrite call sites in every fn / const body. Walk in
// the same pre-order as collect_residuals_ordered so the cursor
// and the per-callsite target list align position-by-position.
let env = build_workspace_env(&ws_owned); // re-build: ws_owned has new defs.
// Snapshot the module list so we can iterate-with-mutation safely.
let module_names: Vec<String> = ws_owned.modules.keys().cloned().collect();
for mname in &module_names {
// Snapshot the def list — we mutate fn / const bodies in place.
let n_defs = ws_owned.modules[mname].defs.len();
for i in 0..n_defs {
// Skip synthesised defs themselves: their bodies were
// built from instance bodies (which used class-param-typed
// `a`, not concrete types). The synth-replay would resolve
// residuals against the substituted concrete type, but
// for 22b.3 we treat synthesised bodies as `pass-through`
// — re-running them under the rewriter is correct iff
// their internal class-method calls are also resolvable.
// For the synthetic 22b.3 fixture, instance bodies are
// λ-without-class-method-calls, so this is moot.
//
// Future (22b.4 default-bearing prelude): default bodies
// like `ne x y = not (eq x y)` reach the rewriter and must
// resolve `eq` to `eq#<concrete-T>` — handled by
// re-running the rewriter on synthesised bodies, which is
// what this loop already does (every Def::Fn including
// synth-appended ones).
let (ordered, defining_module_name): (Vec<Option<MonoTarget>>, String) = {
let m = &ws_owned.modules[mname];
let d = &m.defs[i];
match d {
Def::Fn(f) => (collect_residuals_ordered(f, mname, &env)?, mname.clone()),
Def::Const(c) => {
let pseudo = AstFnDef {
name: c.name.clone(),
ty: c.ty.clone(),
params: Vec::new(),
body: c.value.clone(),
doc: None,
suppress: Vec::new(),
};
(collect_residuals_ordered(&pseudo, mname, &env)?, mname.clone())
}
_ => continue,
}
};
// Now mutate the body in place.
let m = ws_owned.modules.get_mut(mname).unwrap();
let d = &mut m.defs[i];
let ordered_concrete: Vec<MonoTarget> =
ordered.into_iter().map(|o| o.unwrap_or_else(|| MonoTarget {
class: String::new(),
method: String::new(),
type_: Type::unit(),
defining_module: defining_module_name.clone(),
})).collect();
let mut cursor = 0usize;
match d {
Def::Fn(f) => {
rewrite_class_method_calls(
&mut f.body,
&env.class_methods,
&env.workspace_registry,
mname,
&ordered_concrete,
&mut cursor,
);
}
Def::Const(c) => {
rewrite_class_method_calls(
&mut c.value,
&env.class_methods,
&env.workspace_registry,
mname,
&ordered_concrete,
&mut cursor,
);
}
_ => {}
}
}
}
Ok(ws_owned)
```
Behaviour rationale: residuals over non-concrete types (the
`None` slots) signal "no rewrite at this position" — the walker
must still increment its cursor to keep alignment, so we emit a
sentinel `MonoTarget` with empty class/method names and the
walker checks for the empty-class case before mutating. Add the
emptiness guard at the top of the `Term::Var` arm:
```rust
Term::Var { name } => {
if class_methods.contains_key(name) {
if let Some(t) = ordered_targets.get(*cursor) {
if !t.class.is_empty() {
let sym = mono_symbol(&t.method, &t.type_);
let new_name = if t.defining_module == caller_module {
sym
} else {
format!("{}.{}", t.defining_module, sym)
};
*name = new_name;
}
// else: residual was non-concrete → leave name unchanged.
}
*cursor += 1;
}
}
```
- [ ] **Step 6.3 — Run RED test → GREEN.**
Run: `cargo test -p ail --test typeclass_22b3
rewrite_replaces_class_method_var_with_mono_symbol_same_module`
Expected: PASS.
- [ ] **Step 6.4 — Re-run the entire 22b.2 + 22b.3 test suite.**
Run: `cargo test -p ail --test typeclass_22b2`
Expected: ALL PASS (12 tests, no regression).
Run: `cargo test -p ail --test typeclass_22b3`
Expected: ALL PASS (6+ tests).
- [ ] **Step 6.5 — Re-run the workspace test suite to catch
regressions in unrelated crates.**
Run: `cargo test --workspace`
Expected: ALL PASS.
- [ ] **Step 6.6 — Commit.**
```bash
git add crates/ailang-check/src/mono.rs \
crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 22b.3.6: call-site rewrite — same/cross module qualified names"
```
---
## Task 7: End-to-end synthetic fixture + `ail run` validation
**Goal:** A self-contained `examples/test_22b3_mono_synthetic.ail.json`
exercising the full pipeline: class declaration, instance, user fn
calling the class method, `do io/print_int` on the result, build and
run via `ail run`, assert stdout. This is the spec's gating
fixture — "synthetic class+instance fixture in the test suite for
end-to-end validation of the mono pass before the Prelude lands".
**Files:**
- Create: `examples/test_22b3_mono_synthetic.ail.json`
- Modify: `crates/ail/tests/typeclass_22b3.rs`
- Modify: `crates/ailang-surface/tests/round_trip.rs`
### Steps
- [ ] **Step 7.1 — Author the synthetic fixture.**
Create `examples/test_22b3_mono_synthetic.ail.json`:
```json
{
"schema": "ailang/v0",
"name": "test_22b3_mono_synthetic",
"imports": [],
"defs": [
{
"kind": "class", "name": "Foo", "param": "a",
"methods": [{
"name": "foo",
"type": {
"k": "fn", "params": [{ "k": "var", "name": "a" }],
"ret": { "k": "con", "name": "Int" }, "effects": []
}
}]
},
{
"kind": "instance",
"class": "Foo",
"type": { "k": "con", "name": "Int" },
"methods": [{
"name": "foo",
"body": {
"t": "lam",
"params": ["i"],
"paramTypes": [{ "k": "var", "name": "a" }],
"retType": { "k": "con", "name": "Int" },
"body": { "t": "var", "name": "i" }
}
}]
},
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"body": {
"t": "do",
"op": "io/print_int",
"args": [{
"t": "app",
"fn": { "t": "var", "name": "foo" },
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }]
}]
}
}
]
}
```
- [ ] **Step 7.2 — RED test: typecheck-only smoke.**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
/// Synthetic-fixture smoke test: the workspace typechecks (the
/// 22b.2 typecheck arms must not flag anything because the
/// instance is registered, the call site is concrete, and the
/// constraint is implicitly discharged).
#[test]
fn synthetic_fixture_typechecks_clean() {
let entry = examples_dir().join("test_22b3_mono_synthetic.ail.json");
let ws = ailang_core::load_workspace(&entry).expect("load");
let diags = ailang_check::check_workspace(&ws);
assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags);
}
```
Run: `cargo test -p ail --test typeclass_22b3 synthetic_fixture_typechecks_clean`
Expected: PASS — fixture is well-typed by 22b.2's checks.
- [ ] **Step 7.3 — RED test: `ail run` on the synthetic fixture
produces stdout `5\n`.**
Append to `crates/ail/tests/typeclass_22b3.rs`:
```rust
use std::process::Command;
/// End-to-end gate: `ail run examples/test_22b3_mono_synthetic.ail.json`
/// must produce stdout `5\n`. This is the synthetic class+instance
/// fixture spelled out in the parent spec — the existence of this
/// passing test is the 22b.3 close-out criterion.
#[test]
fn synthetic_fixture_runs_and_prints_five() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Locate the workspace ail binary built by `cargo test`.
// `CARGO_BIN_EXE_<bin>` is set by Cargo for any bin in the
// same package; `ail`'s package is `ail`, so the env var is
// `CARGO_BIN_EXE_ail`.
let ail_bin = std::env::var("CARGO_BIN_EXE_ail")
.expect("CARGO_BIN_EXE_ail set by cargo test");
let fixture = examples_dir().join("test_22b3_mono_synthetic.ail.json");
// Use a per-test temp dir so parallel test runs don't collide
// on output paths.
let tmp = manifest_dir.join("target").join(format!(
"test_22b3_synthetic_{}", std::process::id()
));
std::fs::create_dir_all(&tmp).expect("mkdir temp");
let output = Command::new(&ail_bin)
.arg("run")
.arg(&fixture)
.current_dir(&tmp)
.output()
.expect("ail run");
assert!(
output.status.success(),
"ail run failed: stdout={}, stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
stdout.trim_end(), "5",
"stdout = `{stdout}`, stderr = `{}`",
String::from_utf8_lossy(&output.stderr)
);
}
```
Run: `cargo test -p ail --test typeclass_22b3 synthetic_fixture_runs_and_prints_five`
Expected: PASS — the rewrite has turned `foo` into `foo#Int`,
the synthesised fn `foo#Int (i: Int) -> Int = i` exists in the
module, and codegen handles it as an ordinary monomorphic fn.
If the test FAILS, root-cause one of:
a. Codegen rejects the synthesised def (mode-annotation defaults?).
b. The synthesised name `foo#Int` is illegal at codegen — `#`
is not a valid C-ABI char. If so, fix by switching to a
legal substitute (`__` or `$`) in `mono_symbol` and update
Tasks 2/4 tests.
c. The instance body's `Term::Var "i"` references the
`Type::Var "a"` declaration in `paramTypes`, but after
substitution the runtime expects `Int`. If the synthesised
def's `paramTypes` is propagated through to codegen via
`Lam`, fix by setting paramTypes/retType to the substituted
concrete types. (synthesise_mono_fn already substitutes
through `class_method.ty`, but the InstanceMethod's `body:
Lam { paramTypes: [Type::Var "a"] }` survives — see the
fixture above. Fix in synthesise_mono_fn: substitute
`paramTypes` and `retType` of the inner Lam against the
same `param := target.type_` mapping before peeling them
away.)
- [ ] **Step 7.4 — Iterate on root-cause if Step 7.3 is RED.**
Use the diagnostic output of Step 7.3 to identify which of (a),
(b), (c) above bit. Apply the minimal fix to `mono.rs` and the
fixture; re-run.
In particular, for case (c): in `synthesise_mono_fn`, when the
body is `Term::Lam`, also substitute `paramTypes` and `retType`
through the same `mapping`:
```rust
// Inside the `if method_has_params` Lam-body branch:
Term::Lam { params, param_tys, ret_ty, effects, body } => {
// Rebuild the inner Lam — but we just want the inner body
// and the (substituted) paramTypes for FnDef's perspective.
// The outer FnDef's `params` carries the names; `ty`
// carries the substituted Fn type. The body becomes
// *body verbatim, since codegen consults FnDef.ty for
// signatures, not the now-stripped Lam wrapper.
// Effects + ret_ty + paramTypes flow through ty.
let _ = (param_tys, ret_ty, effects); // discarded — ty subsumes them
(params, *body)
}
```
- [ ] **Step 7.5 — Update the surface round-trip skip-list.**
The Form-B parser arms for ClassDef / InstanceDef are still
deferred to 22b.4. The new fixture `test_22b3_*` would fail the
round-trip test as 22b1/22b2 fixtures already do. Edit
`crates/ailang-surface/tests/round_trip.rs` skip-list:
Locate the existing `test_22b2_` entry and add a
`test_22b3_` filter alongside.
Run: `cargo test -p ailang-surface`
Expected: PASS.
- [ ] **Step 7.6 — Re-run the workspace test suite.**
Run: `cargo test --workspace`
Expected: ALL PASS.
- [ ] **Step 7.7 — Run the bench gates.**
Run: `python bench/check.py`
Expected: green or `0` exit (no regression).
Run: `python bench/compile_check.py`
Expected: green or `0` exit.
Run: `python bench/cross_lang.py`
Expected: green or `0` exit (mono pass is a no-op for the
class-free bench corpus, so cross-language ratios should be
identical to pre-22b.3).
- [ ] **Step 7.8 — Commit.**
```bash
git add examples/test_22b3_mono_synthetic.ail.json \
crates/ail/tests/typeclass_22b3.rs \
crates/ailang-check/src/mono.rs \
crates/ailang-surface/tests/round_trip.rs
git commit -m "iter 22b.3.7: synthetic fixture — class+instance compiles, runs, prints 5"
```
---
## Task 8 (controller-side): JOURNAL + close-out
After Task 7 lands, the implement skill (Step 4 — JOURNAL entry)
appends to `docs/JOURNAL.md`:
```markdown
## YYYY-MM-DD — Iteration 22b.3: Monomorphisation pass
<one-paragraph summary describing the mono-pass architecture, the
fixpoint loop, and the synthetic-fixture e2e gate>
Per-task subjects:
- iter 22b.3.1: mono pass skeleton — identity for class-free workspaces
- iter 22b.3.2: mono_symbol — primitive surface forms + hash for compound
- iter 22b.3.3: collect_mono_targets — synth-replay residual gathering
- iter 22b.3.4: synthesise_mono_fn — instance body + class default fallback
- iter 22b.3.5: workspace fixpoint loop — dedup + synth-append
- iter 22b.3.6: call-site rewrite — same/cross module qualified names
- iter 22b.3.7: synthetic fixture — class+instance compiles, runs, prints 5
Known debt:
- Cross-module mono path is implemented but unexercised by 22b.3
fixtures; 22b.4's Prelude module → user-module call exercises it.
- Synthesised FnDefs do not carry mode annotations or effect rows
beyond what the substituted method type naturally provides;
custom-mode user methods (e.g. `borrow` on a method param) flow
through unchanged because `substitute_rigids` preserves
`param_modes`. Spot-check coverage in 22b.4 / 22c.
- Form-B printer / parser arms for ClassDef / InstanceDef remain
deferred to 22b.4; `test_22b3_*` is on the round-trip skip-list.
Next: 22b.4 — Prelude module + Form-B parser/printer arms.
```
The implement skill commits the JOURNAL entry as a separate commit
matching the existing JOURNAL pattern.
---
## Self-review (Step 5 of plan-skill)
1. **Spec coverage.** Spec lists three deliverables for 22b.3:
(a) synthesised FnDefs from `(method, type-hash)` pairs — Tasks
3+4+5. (b) call rewriting — Task 6. (c) cache by key — built
into Task 5's dedup. (d) synthetic class+instance fixture for
e2e validation — Task 7. All covered.
2. **Placeholder scan.** `grep -n "TBD\|TODO\|implement later\|similar to Task\|add appropriate"`
on this plan returns no hits. Note: the plan does include
"Future (22b.4 …)" markers in code-comment strings — those are
intentional documentation, not deferral of plan content; they
describe what the produced mono.rs explains for future readers.
3. **Type / function consistency.** Names referenced across tasks:
`monomorphise_workspace` (Tasks 1, 57), `mono_symbol` (Tasks 2,
4, 6), `MonoTarget` (Tasks 37), `collect_mono_targets` (Tasks 3,
5), `collect_residuals_ordered` (Task 6), `synthesise_mono_fn`
(Tasks 45), `build_workspace_env` (Tasks 3, 56),
`rewrite_class_method_calls` (Task 6). All match.
4. **Step granularity.** Each step is 25 minutes (file edit, test
run, or commit). The largest is Step 6.2 (rewrite walker
implementation) which touches one file and has no judgement
calls beyond the cursor-alignment invariant — already spelled
out. Acceptable.
5. **Cross-task invariants** are listed at the top so the
implementer doesn't have to rediscover them from prose.