832375f2ac
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.
1986 lines
73 KiB
Markdown
1986 lines
73 KiB
Markdown
# 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 `2014–2034`) 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!(¶ms[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(®istry_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, 5–7), `mono_symbol` (Tasks 2,
|
||
4, 6), `MonoTarget` (Tasks 3–7), `collect_mono_targets` (Tasks 3,
|
||
5), `collect_residuals_ordered` (Task 6), `synthesise_mono_fn`
|
||
(Tasks 4–5), `build_workspace_env` (Tasks 3, 5–6),
|
||
`rewrite_class_method_calls` (Task 6). All match.
|
||
|
||
4. **Step granularity.** Each step is 2–5 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.
|