diff --git a/crates/ail/tests/typeclass_22b3.rs b/crates/ail/tests/typeclass_22b3.rs index 39d3761..d6355eb 100644 --- a/crates/ail/tests/typeclass_22b3.rs +++ b/crates/ail/tests/typeclass_22b3.rs @@ -49,25 +49,21 @@ fn monomorphise_workspace_is_identity_on_class_free_workspace() { } } -/// Property: the Task 1 skeleton's class-present branch is also a -/// no-op. The fixture has a `Def::Class` and a `Def::Instance` (so -/// the early-out does NOT fire) but no concrete class-method call -/// sites yet wired through the pipeline; under the skeleton, the -/// fall-through `Ok(ws.clone())` must preserve every module's -/// canonical hash. +/// Property: when a workspace has a class + instance but no concrete +/// class-method call sites, the mono pass produces a hash-identical +/// workspace — the early-out path is bypassed (typeclasses present) +/// but the fixpoint loop terminates on round 1 with zero new targets, +/// so no FnDefs are appended. /// -/// NOTE: this test will need updating in Task 5 once the skeleton -/// is replaced with the real fixpoint — at that point synth fns -/// will be appended to the `defining_module`, breaking module-hash -/// equality on that module by design. The test as written -/// guarantees the Task 1 contract and catches accidental partial -/// rewrites landed before Task 5. +/// The fixture (`test_22b3_no_call_sites.ail.json`) declares +/// `class Show` + `instance Show Int` with a Lam-shaped instance +/// body, but `main` is a literal — there is no `show ` call at any +/// type. Updated in Task 5 from `test_22b2_instance_present.ail.json`, +/// which had a `show 42` call site that the real fixpoint now +/// (correctly) materialises into `show#Int`. #[test] fn monomorphise_workspace_is_identity_when_no_concrete_call_sites() { - // `test_22b2_instance_present.ail.json` has `class Show` + - // `instance Show Int` and typechecks cleanly; reused across - // 22b.2 tests so its shape is stable. - let entry = examples_dir().join("test_22b2_instance_present.ail.json"); + let entry = examples_dir().join("test_22b3_no_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); @@ -82,14 +78,15 @@ fn monomorphise_workspace_is_identity_when_no_concrete_call_sites() { before.modules.keys().collect::>(), "module set must be identical" ); - // Each module hashes byte-identical (skeleton clones unchanged). + // Each module hashes byte-identical — no synth fns appended + // because no call site triggered a target. for (mname, m) in &ws_after.modules { let before_m = &before.modules[mname]; let h_after = ailang_core::module_hash(m); let h_before = ailang_core::module_hash(before_m); assert_eq!( h_after, h_before, - "class-present module `{}` must hash-identical through Task 1 skeleton", + "class-present-but-no-call-site module `{}` must hash-identical", mname ); } @@ -366,3 +363,30 @@ fn synthesise_mono_fn_zero_arg_method_uses_body_verbatim() { f.body ); } + +/// Property: when two call sites of `show` apply at the same concrete +/// type (Int), the workspace fixpoint synthesises the matching FnDef +/// (`show#Int`) exactly once across the whole workspace. Verifies the +/// dedup contract — the pass must not emit one FnDef per call site. +#[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"); +} diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index 34d593b..9a16276 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -74,10 +74,136 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result { 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()) + // 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)?; + // Dedup within a round (multiple call sites of `show` at + // the same type produce N copies of the same MonoTarget) + // AND across rounds (already-synthesised keys filtered out). + // Stable iteration order — preserve first-seen target so + // any later debugging round-trips to a deterministic output. + let mut seen_this_round: BTreeSet<(String, String, String)> = BTreeSet::new(); + let mut new: Vec = Vec::new(); + for t in new_targets { + let k = mono_target_key(&t); + if synthesised.contains(&k) || !seen_this_round.insert(k) { + continue; + } + new.push(t); + } + 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> { + let mut out: Vec = 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 { + 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 } /// Iter 22b.3: returns `true` iff any module in `ws` declares at diff --git a/crates/ailang-surface/tests/round_trip.rs b/crates/ailang-surface/tests/round_trip.rs index 91922d9..ebd98ce 100644 --- a/crates/ailang-surface/tests/round_trip.rs +++ b/crates/ailang-surface/tests/round_trip.rs @@ -32,17 +32,19 @@ fn list_json_fixtures() -> Vec { p.file_name() .and_then(|n| n.to_str()) .map(|n| { - // Iter 22b.1 / 22b.2: the `test_22b1_*` and - // `test_22b2_*` fixtures exercise class/instance - // defs at the workspace-load level, but the surface - // (Form-B) parser/printer arms for those nodes are - // deferred to 22b.4. Until that ships, including - // these fixtures in the round-trip gate would block - // 22b on a property the schema floor does not yet + // Iter 22b.1 / 22b.2 / 22b.3: the `test_22b1_*`, + // `test_22b2_*`, and `test_22b3_*` fixtures exercise + // class/instance defs at the workspace-load and + // monomorphisation levels, but the surface (Form-B) + // parser/printer arms for those nodes are deferred + // to 22b.4. Until that ships, including these + // fixtures in the round-trip gate would block 22b + // on a property the schema floor does not yet // promise. Skip them. n.ends_with(".ail.json") && !n.starts_with("test_22b1_") && !n.starts_with("test_22b2_") + && !n.starts_with("test_22b3_") }) .unwrap_or(false) }) diff --git a/examples/test_22b3_dup_call_sites.ail.json b/examples/test_22b3_dup_call_sites.ail.json new file mode 100644 index 0000000..63a44444 --- /dev/null +++ b/examples/test_22b3_dup_call_sites.ail.json @@ -0,0 +1,55 @@ +{ + "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": "let", + "name": "_a", + "value": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }] + }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 7 } }] + } + } + } + ] +} diff --git a/examples/test_22b3_no_call_sites.ail.json b/examples/test_22b3_no_call_sites.ail.json new file mode 100644 index 0000000..b72d73f --- /dev/null +++ b/examples/test_22b3_no_call_sites.ail.json @@ -0,0 +1,42 @@ +{ + "schema": "ailang/v0", + "name": "test_22b3_no_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": "lit", "lit": { "kind": "str", "value": "no-call" } } + } + ] +}