Ships fn print : forall a. Show a => (a borrow) -> () !IO in the
prelude with explicit-let body \\x -> let s = show x in do
io/print_str s. Three new E2E fixtures + tests verify the full
path:
- show_print_smoke: 4 primitives smoke (print 42 / true / 'hello' /
3.14) — compile, run, expected stdout
- show_user_adt: data IntBox + instance prelude.Show IntBox +
print (MkIntBox 7) — stdout '7'
- show_no_instance: let f : Int -> Int = \\x -> x in do print f —
fires Show-aware NoInstance with DESIGN.md §Prelude(built-in)
classes cross-reference
IR-shape pin in print_mono_body_shape.rs asserts post-mono
print__Int.body is structurally Term::Let → App(show__Int) →
Do(io/print_str). Protects the explicit let-binder for the heap-Str
RC discipline per eob.1 Str carve-out.
Three plan-defects-fixed-inline surfaced during user-ADT E2E,
necessary repairs to make the spec's stated user-ADT trajectory
work end-to-end:
(a) mono.rs (2 sites): MonoTarget::FreeFn::type_args were carrying
bare type-cons references; normalised to canonical
<owner>.<bare> form via workspace_registry.normalize_type_for_lookup
so synthesised cross-module bodies' post-mono walks reach the
registry-keyed instance entries. Symmetric to the existing
class-method-arm normalisation.
(b) codegen/lib.rs (3 sites: resolve_top_level_fn, lower_app
cross-module arm, synth_with_extras Var arm): post-mono
synthesised bodies may carry cross-module references to modules
their source template didn't import (prelude.print__<UserType>
references show_user_adt.show__<UserType> even though prelude
does not import user modules). Fall back to direct
module_user_fns lookup when prefix not in import_map. Both
ends were independently typechecked before mono ran; the
cross-module ref is created by mono not by source.
(c) lib.rs synth FreeFnCall arm: walked Type::Forall.constraints,
substituted rigid vars with fresh metavars, pushed one
ResidualConstraint per declared constraint. Without this,
print f at Int -> Int would silently typecheck and fail with
a confusing 'unknown variable: show' at codegen rather than
fire the right typecheck-time NoInstance diagnostic.
NoInstance Float-aware arm in check/lib.rs:770-779 extended with
a parallel class == 'prelude.Show' branch that cross-references
DESIGN.md §Prelude(built-in) classes verbatim. Negative-fixture
test asserts code 'no-instance' + Show substring + Prelude-(built-in)-
classes substring.
DESIGN.md §Prelude(built-in) classes milestone-24 paragraph flips
fn print from 'ships in 24.3' to 'shipped in iter 24.3' with body
shape + pin file reference. §Float semantics gains a Show-Float
NaN-spelling cross-reference paragraph linking show 1.5 / show nan /
show inf to instance Show Float via float_to_str.
Roadmap P1 'Post-22 Prelude — Show + print rewire' flipped to [x]
with closing summary naming all three shipped iters (24.1 / 24.2 /
24.3). New P2 entry 'Retire io/print_int|bool|float effect-ops +
migrate example corpus to print' inserted at top of P2.
Tests: 556 passed (was 552 + 4 new). bench/cross_lang exit 0;
bench/compile_check + bench/check exit 1 on documented noise-class
metrics per the audit-cma lineage envelope (8th consecutive
audit-grade observation, baseline pristine per conservative-call).
Milestone 24 closes structurally with this iter. Standard audit
pipeline next.
13 KiB
iter 24.3 — fn print polymorphic free fn + E2E + DESIGN.md sync; milestone 24 close
Date: 2026-05-13
Started from: c04c07f
Status: DONE
Tasks completed: 8 of 8
Summary
Ships fn print : forall a. Show a => (a borrow) -> () !IO in
examples/prelude.ail.json with explicit-let body
\x -> let s = show x in do io/print_str s. The let-binder is
structurally pinned by crates/ail/tests/print_mono_body_shape.rs
(post-mono print__Int.body is Term::Let → Term::App(show__Int) → Term::Do(io/print_str)). Three new E2E fixtures + tests verify the
full path: show_print_smoke (4 primitives) and show_user_adt
(data IntBox + instance prelude.Show IntBox + print (MkIntBox 7))
both compile, run, and produce expected stdout; show_no_instance
fires a Show-aware NoInstance diagnostic with the canonical
DESIGN.md cross-reference. DESIGN.md §"Prelude (built-in) classes"
flips print from "ships in 24.3" to "shipped in iter 24.3" with the
explicit-let body + pin reference. DESIGN.md §"Float semantics" gains
a Show-Float NaN-spelling cross-reference paragraph. Roadmap P1
"Post-22 Prelude — Show + print rewire" entry checked off; new P2
entry "Retire io/print_int|bool|float effect-ops + migrate example
corpus to print" inserted at top of P2. Three plan-defects-fixed-
inline surfaced when running the user-ADT E2E: (a) MonoTarget:: FreeFn::type_args were carrying bare type-cons references that broke
the post-mono walk's registry lookup when the synthesised body lived
in a different module from the type — fixed by normalising via
workspace_registry.normalize_type_for_lookup at two
mono.rs collection sites; (b) codegen's cross-module fn resolution
went through import_map, but post-mono synthesised bodies may
reference modules their source template didn't import — fixed by
falling back to direct module_user_fns lookup at three codegen
sites (resolve_top_level_fn, lower_app cross-module arm,
synth_with_extras Var arm); (c) the synth FreeFnCall arm didn't push
residuals for declared constraints, so Show (Int -> Int) never
reached the typecheck-time discharge logic — fixed by walking
Type::Forall.constraints at the FreeFnCall site and pushing one
residual per constraint with the fresh-metavar substitution. Full
cargo test --workspace 556 passed / 0 failed (was 552 post-24.2,
+4 new = 1 print_mono_body_shape + 2 show_print_e2e + 1
show_no_instance_e2e). bench/cross_lang.py clean; bench/compile_check.py
bench/check.pyexit 1 with documented noise-class metrics per the audit-cma lineage envelope (baseline pristine, 8th consecutive audit-grade observation). Milestone 24 closes structurally; only the audit pipeline remains.
Per-task notes
- iter 24.3.1: Appended
fn printtoexamples/prelude.ail.jsonafterfn ge(last def pre-edit). 20 defs total (was 19). Body is the literalTerm::Let { name: "s", value: App(show, [x]), body: Do(io/print_str, [s]) }with the constraintShow abare per mq.1 same-module canonical form. 552 tests stay green. - iter 24.3.2:
crates/ail/tests/print_mono_body_shape.rsasserts post-monoprint__Int.bodyshape. Initial test asserted outerTerm::Lamwrapper — corrected after the panic surfaced thatFnDef.body: Termis the inner function body for top-level fns (params carried separately onFnDef.params). The test now asserts directlyTerm::Let → App(show__Int, [x]) → Do(io/print_str, [s]), faithful to the plan's intent (preserve the explicit-let-binder discipline) while matching the actual AST shape. - iter 24.3.3:
examples/show_print_smoke.ail.jsonwritten with nestedTerm::Seqof fourApp(print, [lit])calls (Int 42, Bool true, Str "hello", Float bits=40091eb851eb851f for 3.14).ail buildclean; stdout:42\ntrue\nhello\n3.14— io/print_str appends a newline via@puts. - iter 24.3.4:
examples/show_user_adt.ail.jsonwritten + 2-testcrates/ail/tests/show_print_e2e.rs. Surfaced three substantive plan-defects-fixed-inline (Concerns below); after fix, both E2E tests pass (print (MkIntBox 7)→ stdout "7"). - iter 24.3.5: Extended NoInstance Float-aware arm in
crates/ailang-check/src/lib.rs:770-779with a parallelclass == "prelude.Show"branch. The arm fires for ANY type lacking a Show instance (function types, user types without instance) — cross-references DESIGN.md §"Prelude (built-in) classes" verbatim. Negative fixtureexamples/show_no_instance.ail.json(let-boundf : Int -> Int+print f) and pin test incrates/ail/tests/show_no_instance_e2e.rs(asserts code, Show-substring, Prelude-(built-in)-classes substring). After the constraint-residual-push fix in synth (Concerns below), the diagnostic fires at typecheck. - iter 24.3.6: DESIGN.md §"Prelude (built-in) classes" milestone-24
paragraph amended:
printflipped to past tense ("shipped in iter 24.3"), body shape + pin file reference added, retirement follow-up named. DESIGN.md §"Float semantics" gained a new paragraph in the "Unspecified" block cross-referencingshow 1.5/show nan/show inftoinstance Show Floatviafloat_to_str. ~10 lines added net (2697 → 2707). - iter 24.3.7: Roadmap P1 "Post-22 Prelude — Show + print rewire"
entry flipped to
[x]with a closing-summary block naming all three shipped iters (24.1f38bad8, 24.2 iter-24.2, 24.3 this iter). New P2 entry "Retireio/print_int|bool|floateffect-ops- migrate example corpus to
print" inserted at top of P2, before the existing operator-routing-through-Eq/Ord entry.
- migrate example corpus to
- iter 24.3.8: Full workspace 556 passed / 0 failed.
prelude_free_fns5/5 green;mono_hash_stability2/2 green (Eq/Ord hashes unchanged, Show hashes from 24.2 unchanged).bench/cross_lang.pyexit 0 (25/25 stable).bench/compile_check.pyexit 1 with 1 noise-class regression on acheck_ms.*metric (regressed metric identity migrates between runs — pattern consistent with the documented noise envelope per audit-cma lineage, 8th consecutive observation).bench/check.pyexit 1 with 2 improvements beyond tolerance onlatency.explicit_at_rc.*— same envelope, same conservative call. Baseline pristine.
Concerns
- Three plan-defects-fixed-inline surfaced during Task 4 (user-ADT
E2E). All three are necessary repairs to make the spec's stated
user-ADT trajectory (§"Data flow / print x at user type IntBox"
lines 297-310) work end-to-end. The plan did not anticipate them
because the test architecture envisioned (Tasks 3+4 sharing one
test file) didn't actually exercise the cross-module mono synthesis
before this iter; primitive-only smoke worked through the existing
pipeline.
- (a) Bare type-cons in
MonoTarget::FreeFn::type_args. At mono target-collection (collect_mono_targets+collect_residuals_orderedfree-fn arms),subst.apply(meta)returns the unification result in whatever qualification the caller-module's view used. For a user-defined type referenced frommain(inshow_user_adt),IntBoxcame out bare. The downstreamsynthesise_mono_fn_for_free_fnthen substituted bareIntBoxeverywhere in the synthesised body, and Phase 3 rewrite of the synthesised body (run in PRELUDE caller-module context, whereIntBoxis not a known TypeDef) sawType::Con{IntBox}that didn't normalise to the registry-keyedshow_user_adt.IntBox. Lookup missed → noshow__IntBoxtarget scheduled → bareVar "show"persisted post-mono. Fix:mono.rslines 685-696 + 1267-1278 normalise viaenv.workspace_registry.normalize_type_for_lookup(module_name, &resolved)before pushing the type intoMonoTarget::FreeFn::type_args. Same invariant applies symmetric to the existingr_ty_norm = normalize_type_for_lookup(...)calls already incollect_mono_targets's class-method arm. - (b) Codegen cross-module references via import_map. Post
fix-(a), the synthesised body now correctly references
show_user_adt.show__cb5eb497from insideprelude.print__cb5eb497. Butpreludedoes not haveshow_user_adtin itsimport_map(prelude is the base; user modules import IT, not the other way around). Three codegen sites raised UnknownVar:resolve_top_level_fn:2200,lower_appcross-module arm:1981,synth_with_extrasVar arm:2777. All three now fall back to a directmodule_user_fns.contains_key(prefix)(ormodule_def_ail_types.contains_key(prefix)) lookup when the prefix isn't in the current module's import_map. This is the post-mono synthesised-body invariant: cross-module references in synthesised bodies may bypass the source template's imports (both ends were independently typechecked under their own module contexts before mono ran; the cross-module reference is created by mono, not by the source). Doc comments cross-reference all three sites. - (c) Poly-free-fn constraint residuals not pushed at synth.
Pre-iter-24.3, the FreeFnCall arm at
lib.rs:2820hadconstraints: _— declared constraints of the poly free fn were ignored. For all milestone-23 fixtures (ne/lt/le/gt/geat primitive types), this was latent because every call site had a registry entry; the mono pass silently scheduled the correspondingeq__T/compare__Ttargets, and codegen succeeded. But for negative cases —print fwheref : Int -> Inthas no Show instance — the constraint never reached the typecheck-timeNoInstancedischarge (lib.rs:1880-1905). The build would emit a confusing post-monounknown variable: showfrom codegen rather than the right typecheck-phase diagnostic. Fix: atlib.rs:2820+, walkType::Forall.constraintsat FreeFnCall sites, substitute the rigid vars with their fresh metavars, and push oneResidualConstraintper declared constraint. The discharge loop then fires the correct NoInstance with full diagnostic context (including the Show-aware addendum from Task 5). Pre-existing milestone-23 tests stay green because primitive-type Show/Eq/Ord registry entries discharge silently.
- (a) Bare type-cons in
- bench/compile_check.py + check.py exit 1. Both report
noise-class metrics (1 regression / 2 improvements respectively).
Metric identity migrates between consecutive runs — pattern
consistent with the documented
latency.implicit_at_rc.*/check_ms.*noise envelope per audit-cma + audit-ms + audit-eob + audit-ct-tidy + audit-mq + iter-24.2 lineage. 8th consecutive observation. Baseline pristine per conservative-call convention. - Plan deviation on Task 2 (IR-shape pin). The plan's test stub
asserted
Term::Lam { body: Term::Let { ... } }forprint__Int. The actualFnDef.body: Termshape for top-level fns is the inner function body (params separate); no outerTerm::Lam. The test was corrected to assertTerm::Let { ... }directly. The load-bearing property (explicit let-binder for heap-Str RC discipline) is identical between the plan's intent and the shipped assertion; only the wrapper assumption changed. Doc comment in the test file explains the FnDef.body shape contract.
Known debt
- Milestone 24 closes structurally with this iter. The standard
auditpipeline (architect drift review + bench regression attribution) runs next. The three plan-defects-fixed-inline above may merit architect commentary on whether the fix sites belong as permanent invariants or are themselves transitional. Specifically: fix (a) implies thatMonoTarget::FreeFn::type_argscarries CANONICAL types post-collection — this is a strengthening of the pre-iter invariant which was "subst.apply result"; future planner work that touches the mono pass should keep that strengthening visible. - P2 entry "Retire io/print_int|bool|float effect-ops" is queued.
The new entry's
~86 fixtures affectedcount is from a rough grep againstdo io/print_<T>; precise count + migration plan is the planner's job when the milestone opens. - Negative-fixture coverage is single-shape.
show_no_instance.ail.jsonusesf : Int -> Int. Other NoInstance shapes (user type without instance,print xwherex : SomeUnsupportedClass) are not separately tested — the NoInstance arm fires for any non-prelude-Show type by construction, so the single fixture is structurally representative.
Files touched
Created (6):
crates/ail/tests/print_mono_body_shape.rscrates/ail/tests/show_print_e2e.rscrates/ail/tests/show_no_instance_e2e.rsexamples/show_print_smoke.ail.jsonexamples/show_user_adt.ail.jsonexamples/show_no_instance.ail.json
Modified (6):
examples/prelude.ail.json(+1 def:fn print)crates/ailang-check/src/lib.rs(NoInstance Show-aware branch + FreeFnCall constraint-residual push)crates/ailang-check/src/mono.rs(FreeFn type_args canonical-form normalisation, 2 sites)crates/ailang-codegen/src/lib.rs(cross-module reference fallback for post-mono synthesised bodies, 3 sites:resolve_top_level_fn,lower_app's cross-module call arm,synth_with_extras's Var arm)docs/DESIGN.md(§"Prelude (built-in) classes" milestone-24 paragraph + §"Float semantics" Show-Float cross-reference)docs/roadmap.md(P1 Show+print → [x]; new P2 entry at top)
Stats
bench/orchestrator-stats/2026-05-13-iter-24.3.json