# JOURNAL Chronological notes for myself. Not every change; only decisions, obstacles, and observations that future iterations will need. ## 2026-05-07 — Day 0 - Repo initialised. Assignment in `CLAUDE.md`: LLM-native language, LLVM backend. - Design decisions captured in `docs/DESIGN.md`. - Toolchain: `rustc 1.94`, `llvm-config 22.1.3`, `clang` available. - Decided against `inkwell` in favour of LLVM IR text emit. Rationale in DESIGN.md. - Workspace layout: - `crates/ailang-core` — AST, type, hash, JSON schema - `crates/ailang-check` — typechecker (comes later) - `crates/ailang-codegen` — lowering + LLVM IR emit - `crates/ail` — CLI - MVP goal: `examples/sum.ail.json` → binary that prints 55. **Achieved.** ## 2026-05-07 — architecture review after the MVP Still on track? Broadly yes. Concrete observations: **What holds:** - JSON AST + canonical form + content hash are all lego bricks that later tools can build on without a refactor (`ail deps`, `ail diff`). - The LLVM IR text pipeline works as planned. No libllvm version pain. - The effect set is wired into the type system from the start. Extensible to row-poly without touching the core. **Debt that accrues interest:** 1. **`current_block_label_for_phi` is a heuristic** (see codegen). On nested `if` terms it will return the wrong block label, because it scans the body backwards. Ticking, because no test cases trigger it yet. Must be fixed next, before new language features arrive. 2. **No typed AST.** Codegen reads the source AST directly and relies on the typechecker having run before. Fine for the MVP; once ADTs or closures arrive, I will need a separate typed IR stage (TIR). 3. **The `hash` field is not in the AST.** Right now we hash the def object directly. Once I serialise hashes as fields (caching), the hash will need to exclude that field before computation. **Plan iteration 2 (now):** 1. Clean up block-label tracking, with a nested-if test. 2. Strings as a literal + `io/print_str`. 3. Hello-world example as a second E2E test. 4. CLI: `--json` output for machine consumers wherever it fits. **Plan iteration 3:** ADTs + pattern matching. That is the next big jump. Requires a typed IR stage (TIR), because pattern matching lowers into decision trees, which have a different shape from the AST. ## 2026-05-07 — iteration 2 done - Block-label tracking is now robust (nested `if`s work). Test `max3_picks_largest` protects it. - Strings as `Lit::Str { value }`, type `Str` -> LLVM `ptr`, with `io/print_str` effect op. `examples/hello.ail.json` prints a string. - CLI: `manifest --json`, `builtins --json` for tool consumers. - `ail deps [--of NAME] [--json]` lists call edges. Effect ops are tagged `effect:NAME` so a consumer can filter them. **Architecture check:** no structural deviations. Codegen still reads the source AST directly (a TIR stage will become necessary with ADTs in iteration 3). ## 2026-05-07 — iteration 3 done: ADTs - TypeDef in the AST with ctors. A ctor has `name` and `fields: [Type...]`. - Term::Ctor (construction) and Term::Match (pattern matching). - Patterns: `Wild`, `Var`, `Lit`, `Ctor { ctor, fields }`. In the MVP, nested ctor patterns are NOT allowed — sub-patterns must be `Var` or `Wild`. - Typechecker with a type registry and `ctor_index` (ctor name → ADT). In Match, exhaustiveness is checked against the full constructor set. A negative test protects this. - Codegen: boxed heap layout. Per ctor application, `malloc(8 + 8*n)` bytes; tag at offset 0, fields from offset 8 (8-byte slots, native typed load/store). Match: load tag + switch + arm blocks + phi at the join. - `examples/list.ail.json` (Cons/Nil list, sum_list via match) returns 42. **Surprisingly painless.** The architecture decisions from day 0 paid off: opaque ptr in LLVM 22 makes the boxed layout almost glue-free; effect tracking was untouched by ADTs; the JSON AST takes new node types cleanly. **Debt accrued:** 1. **Codegen still reads the source AST directly.** The temptation to push on without TIR was strong — and worked, because my Match restrictions are flat (no nested patterns). Once nested patterns arrive, decision- tree lowering will not stay clean without TIR. Debt acknowledged; not due now. 2. **No GC.** The heap leaks. Acceptable for demo programs; must be addressed before any longer-running program. Options for Phase 4: refcount, Boehm-GC linkage, region inference. 3. **No runtime pretty-printer for ADT values.** `io/print_int` is enough for demos, but a generic `show :: a -> Str` for ADTs would be valuable. Requires dispatch over the tag — feasible, but not now. **Plan iteration 4:** The next steps are less obvious. Three candidates in priority order: 1. **Module system (imports).** Right now everything is in a single module. With multiple modules + cross-module hashing the language only becomes practical for several defs. 2. **Structured error output (`ail check --json`).** So tools can react to type errors without parsing text. 3. **Closures / higher-order functions.** Requires closure conversion and is a bigger step. Iteration 4 will be (1) + (2) — both strengthen the LLM tooling and have moderate risk. ## 2026-05-07 — workflow change: orchestrator + agent repo At the user's suggestion, switching to **orchestrator mode**: I delegate clearly bounded implementation chunks to sub-agents and keep only architecture decisions, reviews, and commit discipline. Four specialised agents drafted: implementer, architect, tester, debugger. **Important correction:** the user required the agents not to be hidden in `.claude/agents/`, but versioned as a visible part of the project under `agents/`. DESIGN.md gained a new section "Project ecosystem", which records this: AILang is not just a language, but language core + CLI + examples + agents + docs + tests, all of equal weight. Invocation scheme: the system-prompt body from `agents/.md` as a prefix before the concrete task + sent to the `general-purpose` agent. Functionally identical to subagent loading from `.claude/agents/`, but visible in the repo. **Plan iteration 4 (revised):** The module system is more involved than expected (cross-module hashing, import resolution). First the smaller tooling wins, then the module system as iteration 5: 1. **Structured error output** (`ail check --json` with a Diagnostic struct, stable codes like `unbound-var`, `type-mismatch`). 2. **`ail diff `** — semantic module diff via per-def hash comparison. 3. **IR snapshot tests** — regression protection for the codegen pipeline. ## 2026-05-07 — iteration 4 done: LLM tooling consolidation Three sub-commits, each produced by an `ailang-implementer` invocation and spot-checked by the orchestrator: - `93fe723` Iter 4a: `ail check --json` with a `Diagnostic` struct (`severity`, `code`, `message`, `def`, `ctx`). Stable codes: `unbound-var`, `type-mismatch`, `arity-mismatch`, `non-exhaustive-match`, `unknown-ctor`, `unknown-ctor-in-pattern`, `nested-ctor-pattern-not-allowed`, `duplicate-def`, `unknown-effect-op`, `unknown-type`, `schema-mismatch`. API: `check_module(&Module) -> Vec`. - `c652b12` Iter 4b: `ail diff [--json]` as a structural top-level def diff via BLAKE3 hash. Four categories (added/removed/changed/ unchanged), sorted alphabetically, exit code 1 on diff. - `74a2005` Iter 4c: IR snapshot tests in `crates/ail/tests/snapshots/{sum,max3,hello,list}.ll`. Normalisation of `target triple`. Update via `UPDATE_SNAPSHOTS=1 cargo test ir_snapshot_`. Mismatch produces an `.actual` file. Test count: 28 (previously 19). 7 E2E + 4 IR snapshot + 9 ailang-check + 1 ailang-codegen + 7 ailang-core. **Closed from the debt register:** - Block tracking in codegen has not been a heuristic risk since Iter 2; the explicit `current_block: String` track is now additionally protected against regression by Iter 4c snapshot tests. Debt closed. **New / sharpened debt:** 1. `check_module` is **single-shot** — the first error aborts, no multi-diagnostic gathering. The spec was that way, but the format suggests Vec semantics. A real multi-diagnostic refactor will be cheaper once TIR exists (a central error accumulator via a separate stage). Not due now. 2. `source_filename` in the IR is hard-coded to `".ail"`. As long as there is only one top-level module, that is platform-stable. With Iter 5 (module system + imports) the path becomes relevant — keep it path-independent at construction time, otherwise the snapshots will tip over. **Plan iteration 5:** module system with imports. Cross-module hashing, import resolution, multiple `.ail.json` files in one build. The multi- diagnostic refactor only after that. Sub-steps: - **5a — workspace loader.** `ailang_core::Workspace { modules: BTreeMap }` plus `load_workspace(entry: &Path)`, which follows `imports` recursively from the entry module. Convention: `import { module: "foo" }` resolves to `/foo.ail.json` next to the entry. Cycle detection. CLI: existing subcommands keep working on a single module; a new `ail workspace ` lists all reachable modules with hash. Tests: two small example modules with an import relation; cycle test. - **5b — cross-module typecheck.** The typechecker takes `&Workspace` instead of `&Module`. Imports are mounted in the env as a namespace (`alias.def` or, with no alias, `module.def`). New diagnostic codes: `unknown-module`, `unknown-import`, `import-cycle`, `ambiguous-name`. Tests per code. - **5c — cross-module codegen.** The emitter produces IR for all modules in the workspace, prefix-mangled with `@ail__`. E2E test: a program that uses a function from module B in module A returns the correct result in the binary. - **5d — tooling adjustments.** `manifest`, `describe`, `deps`, `diff` gain a `--workspace` mode (recursive). The single mode stays the default for backwards compatibility. During Iter 5, at construction time **keep `source_filename` path-independent** (module name only, no directory prefix), otherwise the IR snapshots will tip over. ## 2026-05-07 — Iter 5b done: cross-module typecheck - `check_workspace(&Workspace) -> Vec` as the top-level API. `check_module` is preserved and internally lifts the module into a trivial workspace. - Convention for qualified references (recorded in DESIGN.md): `Term::Var { name }` with exactly one dot = `.`. Prefix is an import alias or module name. No new AST node, no renamed fields ⇒ hashes stay stable; all `ir_snapshot_*` still green. - Three new diagnostic codes: `unknown-module`, `unknown-import`, `invalid-def-name` (with `ctx.reason: "contains-dot"`). - CLI: `ail check ` now **always** loads via `load_workspace`. Workspace load failures become structured diagnostics in JSON mode with codes `module-not-found`, `module-cycle`, `module-name-mismatch`, `module-hash-mismatch`, `schema-mismatch`. `ail build` and `ail emit-ir` stay per single module (cross-module codegen is 5c). - Examples: `ws_main.ail.json` now calls `ws_lib.add` (observable). New: `ws_broken.ail.json` (`unknown-import`), `ws_unknown_module.ail.json` (`unknown-module`). - Tests: 37 green (previously 32). 4 new workspace integration tests in `crates/ailang-check/tests/workspace.rs`, one new e2e test `check_workspace_resolves_import`. - Debt: single-shot diagnostics still in place (multi-diagnostic after 5c). The dot convention covers exactly one dot — nested module paths (`a.b.c`) do not exist; that would only be a topic with hierarchical modules and currently falls through as `unbound-var`. ## 2026-05-07 — Iter 5c done: cross-module codegen - **Mangling break (deliberate).** All AILang functions are now called `@ail__`, even in single-module programs. The old form `@ail_` is gone. Strings/const globals analogously (`@.str___`, `@ail__`). The entry point stays `main` as C ABI: a `define i32 @main()` trampoline calls `@ail__main()`. If the entry module has no `main : () -> Unit !IO`, the build fails with `MissingEntryMain`. - **Workspace lowering.** New top-level API `ailang_codegen::lower_workspace(ws: &Workspace) -> Result` produces a single `.ll` for the whole workspace. Modules in alphabetical order (BTreeMap order); defs in AST order. Cross-module calls are resolved in codegen via the import map of the calling module — same logic as in the typechecker, locally duplicated with a cross-reference (no shared helper module, because the type worlds differ: the typechecker handles `Type`, codegen handles `FnSig` from llvm types). - **CLI.** `ail build` and `ail emit-ir` now always load the workspace and check/lower it fully. Single-module programs keep working (trivial workspace with one module). `emit_ir(m)` stays in the codegen crate as a convenience API and internally wraps into a trivial workspace. - **Snapshots regenerated.** `sum.ll`, `max3.ll`, `hello.ll`, `list.ll` show the new mangling. New `ws_main.ll` snapshot documents the cross-module build: `@ail_ws_main_main` calls `@ail_ws_lib_add`. - **Tests.** 40 green (previously 37). New: `workspace_build_runs_imported_fn` (e2e: prints 5), `ir_snapshot_ws_main`, `missing_entry_main_is_error` (codegen unit). Existing behaviour tests (`sum_1_to_10_is_55`, `max3_picks_largest`, `hello_world_str_lit`, `list_sum_via_match`) stay green — behaviour unchanged, only the mangling is new. **Debt closed:** - **#19 (`source_filename` hardening).** In the workspace world, `source_filename` is now uniformly `.ail`, once per workspace. The previous hard-coded path dot is gone with it. **State:** the module system is closed end to end — loader + typecheck + codegen + build see the workspace as a coherent unit. The multi- diagnostic refactor and possibly cross-module ADTs remain for later. ## 2026-05-07 — Iter 5d done: tooling extended to the workspace - `ail manifest|describe|deps|diff --workspace` now operate across all modules of the workspace. The default without the flag stays single-module for backwards compatibility. Manifest sorts by `(module, name)`, describe accepts dotted notation `ws_lib.add`, deps emits `{from_module, from_def, to_module, to_def}` edges, diff compares workspace-wide with added/removed/changed/unchanged_modules and a nested sub-diff per changed_module. - Refactor: `diff_def_lists` is the single source of the four-category logic; single and workspace diff share it. - Tests: 44 green (previously 40). New: `manifest_workspace_lists_all_defs`, `describe_workspace_resolves_qualified_name`, `deps_workspace_includes_cross_module`, `diff_workspace_added_module`. **Observation (debt):** `deps` does not filter builtins/locals/function parameters. In workspace mode that becomes more visible than in single mode — `ws_lib.add` lists edges to `ws_lib.+` (builtin) and `ws_lib.a`/`ws_lib.b` (function parameters). A known pre-existing issue from Iter 2; Task #22 in the backlog. ## 2026-05-07 — architecture review after Iter 5 Architect agent invoked. Findings: 1. **Mangling consistency holds.** `@ail__` is consistent across functions, constants, string globals, and cross-module calls. The trampoline is correct. ADT constructors are deliberately symbol-free (inline malloc). 2. **Module hashes bit-identical since Iter 4.** The Iter 5c snapshot regeneration was a codegen-output change, not a hash break. 3. **Drift, due now:** - DESIGN.md says `define i64 @main()`, codegen emits `define i32 @main()` (see `sum.ll:35`). - String-schema notation in DESIGN.md was shortened (`@.str__` instead of `@.str___`). 4. **Debt that accrues interest:** the `deps` builtin leak (Task #22) has become a falsehood in workspace mode — close it before the next big jump. **Plan iteration 6 — clean-up:** 1. **Fix DESIGN.md drift.** Update the mangling-scheme block, correct the `@main` signature, and note the string globals precisely. 2. **`deps` hardening (#22).** Build a top-level def table per workspace; filter edges whose target is not a top-level symbol, or emit them as separate `builtin:`/`local:` categories. Function parameters via lexical scope tracking from walk_term. 3. **Multi-diagnostic refactor (#20).** `check_workspace` accumulates `Vec` across all defs instead of short-circuiting on the first error. Intra-def may still short-circuit — the value is "see all broken defs at once", not "see all broken sub-terms of one def". Order: 1 first (doc triviality), then 2 before 3 (deps is a tooling- truth fix, multi-diag is a structural extension). ## 2026-05-07 — Iter 6 done: deps hardening + multi-diagnose + DESIGN audit Three things landed together. All small, all KISS — no architecture move, just paying off recorded debt. **1. `ail deps` filters builtins, params, and let/match bindings (#22).** Before: `sum -> +, -, ==, n, sum` and `ws_lib.add -> ws_lib.+`, `ws_lib.a`, `ws_lib.b`. After: `sum -> sum`, `ws_lib.add -> ws_lib.add` gone (no real deps; only the cross-module call from `ws_main` remains). Implementation: - New helper `ailang_check::builtins::value_names()`: derives the Var-level builtin names (`+ - * / % == != < <= > >= not`) from `list()`, so the install-list and the deps-filter share one source of truth. - `walk_term` in `crates/ail/src/main.rs` now threads a `scope` set: fn-params seed it; `Let` adds the bound name for the body only; `Match` arms add their pattern variables (`bind_pattern` helper, MVP rule "ctor sub-patterns are Var/Wild") and roll them back after. Var refs that hit `scope` or `builtins` are dropped; qualified names (`prefix.def`) are passed through unconditionally — the typechecker forbids dots in def names, so no shadowing risk. - Tests added: `deps_filters_builtins_params_locals`, `deps_workspace_filters_builtins_and_params`. The Iter 5d test (`deps_workspace_includes_cross_module`) keeps passing — the only edge it asserted is the legitimate one. **2. `check_module` / `check_workspace` are multi-diagnose (#20).** `check_in_workspace` returns `Vec` instead of `Result<()>`. Pass-1 (top-level symbol table) stays fail-fast — corrupt globals would taint every later diagnostic. Type-def installation is fail-fast within a module (env corruption) but the outer module loop continues. The body-check loop is the multi-diagnose layer: each def is checked against the assembled env, errors accumulate, the next def is attempted. Test: `body_errors_accumulate_across_defs` — one module with two independent body errors (arity mismatch + unknown var) yields two diagnostics with the right `def` field. The legacy single-error `check` keeps working by `.into_iter().next()`-ing the Vec, so internal snapshot tests in `crates/ailang-check/src/lib.rs` are unchanged. Out of scope: intra-def collection. A single fn body with three type errors still reports one. The "see all broken defs at once" goal is met; intra-def will require unification deferral and isn't due now. **3. DESIGN.md `What the MVP is NOT` audit (#24).** The section was lying: it claimed "No ADTs / pattern matching" (delivered Iter 3) and "Only ints + bools + unit" (strings landed Iter 2). Renamed to `What is not (yet) supported`, restructured into "not yet" + "what is supported (smoke-tested)". New invariant: this section is meant to be the truth at the **end of the latest iteration**, not a 2026-05-07-day-0 scope statement. **Architecture check (the user-asked self-questioning):** - *Would I use this language now?* For non-recursive arithmetic + ADT programs over int/bool/str: yes, comfortably. For anything that needs mapping, folding, generic data structures: no, closures are the blocker. That's the next big sprint, not Iter 7. - *Consistency:* DESIGN.md, JOURNAL.md, code, and CLI output now agree on what the language can do. The "What is not (yet) supported" block is the canonical truth surface. - *Visualisation:* `ail deps --workspace --json` is now a clean cross-module call graph (no builtin noise). Good enough for an external graph renderer to consume; a built-in DOT/ASCII renderer is *possible future tooling*, not "we need it now". KISS. - *Documentation:* the agents/ directory is the sub-prompt layer, the JOURNAL is the iteration log, DESIGN.md is the contract. No new doc axes needed at this scale. **Tests:** 47 green (previously 44). +2 deps tests in `e2e.rs`, +1 multi-diag test in `crates/ailang-check/tests/workspace.rs`. **Plan iteration 7:** Closures + higher-order functions. This is the big jump that DESIGN.md / Day 0 has been pointing at: it requires a typed IR (TIR) stage, closure conversion in lowering, and a heap-aware ABI. The multi-diag refactor in Iter 6 was scoped intentionally minimal — when TIR lands, intra-def diagnostics become structurally cheap and Task #20 gets revisited. ## 2026-05-07 — Iter 7 done: first-class function references (no capture) Iter 6 outlined Iter 7 as "closures + HOFs + TIR". KISS course-correct on inspection: that bundle had three independent things in it, and the HOF use-cases (passing functions around, calling through fn-typed parameters) need none of TIR or capture. Splitting paid off — what landed here is ~120 LOC of codegen, no TIR, no heap, no ABI churn. Closures with capture stay queued for Iter 8 (where TIR is the correct precondition). **What works now:** - Top-level fn name (or qualified `prefix.def`) used as a value yields an LLVM fn-pointer (`@ail__`, type `ptr`). - Fn-typed parameters can be called as `f(args)` — the body emits an indirect `call () %f(...)`. - Pass through `let`: `let g = inc in g(x)` works (the local just aliases the global SSA, the sidetable lookup still hits). - Pass to another fn: `apply(inc, 41) == 42` — see `examples/hof.ail.json`, exercised end-to-end. **What does not (yet) work — by design:** - No anonymous lambdas. The only fn-value source is a top-level def reference. - No capture. A fn-value is always a constant pointer to a top-level def; there is no environment to allocate. - Both deferred to Iter 8 where they share the TIR + closure-conversion preconditions. **Implementation, in order of where the rubber meets the road:** 1. `llvm_type` learned `Type::Fn { .. } -> "ptr"`. The actual signature travels separately. New helper `fn_sig_from_type` lifts an AILang fn-type into an `FnSig` (LLVM types only). 2. `Emitter` got a sidetable: `ssa_fn_sigs: BTreeMap`, keyed by SSA value (or `@global`). It's reset per function body. 3. At `emit_fn` entry, every fn-typed parameter registers `(%arg_, sig)` in the sidetable. 4. `lower_term(Term::Var)` now falls through to a top-level fn lookup (`resolve_top_level_fn`) when the name isn't a local. The returned SSA is the global symbol; the sidetable gets the sig. 5. `lower_term(Term::App)` dispatches: - if callee is a `Var` AND not shadowed AND statically known (`is_static_callee` covers builtin operators, qualified `prefix.def`, current-module fns), keep the existing direct `lower_app` path — no extra indirection in the IR; - otherwise lower the callee, expect type `ptr`, look up the sig in the sidetable, emit `emit_indirect_call`. 6. `Term::If` propagates the sig to its phi SSA when both branches are fn-pointers with matching sigs (cheap two-line copy; no separate test, falls out of the `apply`-on-conditional pattern). **Why no typechecker change?** The typechecker already accepted fn-typed locals (`Term::Var` against `env.globals`, App via `synth(callee)` unifying with `Type::Fn`). The only blocker was `MVP: callee must be a variable` in codegen. **Tests:** 48 green (previously 47). - `crates/ail/tests/e2e.rs::higher_order_apply_inc` builds and runs `examples/hof.ail.json`, asserts the binary prints `42`. - Existing tests unchanged (incl. snapshot tests around the IR emission for `sum`, `list`, `max3`). **Architecture self-check:** - *Would I use this language now?* Yes for `apply`-style and "pass a predicate" patterns. Still no for capturing closures (`let n = 3 in map(\x -> x + n, xs)`-equivalent), but the ergonomic gap shrank. - *Consistency:* DESIGN.md "What is not (yet) supported" rewritten in the same edit; first-class fn-refs now have a positive bullet, the closures bullet is precise about what it means (no capture, no lambdas). - *Visualisation:* `ail describe`/`manifest` already render fn-typed params correctly via the existing `pretty::type_to_string` (`((Int) -> Int, Int) -> Int`). No tooling change required. - *KISS:* every alternative I considered (full `LocalType` enum, swapping `(String, String)` returns to a typed wrapper, lifting lambdas to defs as syntactic sugar) was strictly more code than the sidetable approach, with no expressivity gain. **Plan iteration 8:** Closures with capture, anonymous lambdas, the typed IR (TIR) layer, closure conversion in lowering. Now that we have indirect calls working, the main delta is: a fn-value also needs an environment pointer, the sidetable becomes per-value (heap-allocated), and the calling convention shifts to `(env_ptr, args...)`. Touches every existing call path — that's why it gets its own iteration. ## 2026-05-07 — Iter 8 done: closures with capture (no TIR needed) Iter 7's plan named TIR as the prerequisite for closures. On inspection that bundling was wrong — TIR is one possible implementation strategy, not a structural requirement. The typechecker already attaches enough type information through `synth` that the codegen can read capture types out of `self.locals` directly. So Iter 8 ships closures **without** introducing TIR. KISS won. The work split into two commits: **Iter 8a — closure-pair ABI flip.** Every fn-value is now a `ptr` to a heap or static closure pair `{ thunk_ptr, env_ptr }`, regardless of whether it came from a lambda or a top-level def reference. To keep top-level-fn references cheap, every top-level fn auto-emits: ```llvm define @ail___adapter(ptr %_env, ) { %r = call @ail__() ret %r } @ail___clos = constant { ptr, ptr } { @adapter, null } ``` `Term::Var` resolving to a top-level fn returns the address of `_clos`, never the bare fn pointer. `emit_indirect_call` was rewritten to GEP+load both halves and call `thunk(env, args...)`. Direct calls (statically-known callees in `Term::App`) bypass the adapter and stay at the original speed. The Iter 7 hof example (`apply(inc, 41)`) continues to print 42 unchanged — only the IR shape changed, not the source. IR snapshot files for sum/list/max3/hello/ws_main were refreshed. **Iter 8b — Term::Lam + capture + lambda lifting.** New AST node: ```jsonc { "t": "lam", "params": ["x"...], "paramTypes": [Type...], "retType": Type, "effects": ["..."], "body": Term } ``` Param/return types are explicit. The typechecker accepts the declared `Type::Fn` shape, checks the body's type against `retType`, and verifies that body effects are a subset of the declared lambda effects (no row polymorphism in the MVP). Constructing a lambda is pure; the act of *calling* picks up the declared effects, via the existing App branch. Codegen does textbook closure conversion: 1. **Free-variable analysis.** `collect_captures` walks the body skipping builtins (`+`, `==`, ...), the current module's top- level fns, and qualified `prefix.def` names. The remainder are captures. Inner lambdas contribute their own free vars upward. 2. **Lift to thunk.** For each lambda, generate a fresh `@ail___lam(ptr %env, params...)`. State the body into a side buffer (the emitter's `body`/`locals`/`counter` are saved and reset, then restored). Captures and lambda params are pushed as named locals so the body lowering finds them. The thunk text goes into a `deferred_thunks` queue and is appended after the parent fn's `}` — LLVM IR doesn't care about fn order. 3. **Pack at the use site.** In the OUTER body emit: ```llvm %env = call ptr @malloc(i64 <8 * captures>) ; for each capture i: store at offset 8*i %clos = call ptr @malloc(i64 16) ; store thunk_ptr at offset 0, env at offset 8 ``` `%clos` is the value returned by the Lam term. Its sig is registered in the sidetable so subsequent indirect calls work. 4. **Capture sigs propagate.** A fn-typed capture (e.g. capturing a fn-typed param of an outer scope) keeps its FnSig in the thunk's sidetable, so the captured fn can still be indirect-called from inside the lambda. Capture layout uses 8-byte slots regardless of LLVM type. Typed load/store reads only the bytes it needs — wasted padding for `i1` and `i8` is fine at this scale. **Architecture self-check:** - *Would I use this language now?* Yes for substantially more cases. `let n = 3 in apply(\\x. x + n, 39)` is the example I would have reached for in Iter 6 and bounced off. It now compiles and runs. `map`/`fold`/`filter` over user-supplied predicates are within reach — only the absence of polymorphism still forces author-side monomorphisation. - *Did I think of everything?* Hash stability checked manually: `examples/sum.ail.json` produced the same fn hashes (`db33f57cb329935e`, `d9a916a0ed10a3d3`) before and after Iter 8. Existing modules without `Term::Lam` serialise bit-identically. ✓ - *Consistency:* DESIGN.md "What is not (yet) supported" rewritten in the same edit. The Term schema gained `lam`, `ctor`, `match` rows that were already supported but had been omitted from the schema fragment. Now the doc is exhaustive for the supported language. - *Visualisation:* `ail describe` already renders Lam terms (added pretty-printer rule), and the codegen IR for `closure.ail.json` reads as a textbook closure-conversion lowering. - *KISS check:* I considered three alternatives and all were strictly worse — fat-pointer ABI (aggregate-passing concerns), full TIR layer (large rewrite), uniform heap pair without static-closure optimisation (regressed Iter 7 to one malloc per fn-value escape). **Tests:** 49 green (was 48 after Iter 7). One new e2e: `closure_captures_let_n` builds and runs `examples/closure.ail.json` asserting "42". IR snapshot files refreshed for the per-fn adapter + static-closure scaffold — only structural delta. **Plan iteration 9:** Two candidates, both real pain points: 1. **Polymorphic inference.** Make `Type::Forall` actually work in `synth` — instantiate fresh type variables at each use site, allow `let id = \\x. x in (id 1, id true)`. This unblocks generic `map`/`fold`/etc. without per-type clones. Probably small (~150 LOC in the typechecker; codegen already monomorphises by instantiation when it lowers the call). 2. **GC / region reclamation.** Right now ADT boxes, lambda envs, and closure pairs all leak through the program's lifetime. A minimal mark-and-sweep over a tagged heap would let us run real programs. Bigger lift, ~400-600 LOC plus runtime support. Leaning toward (1) for the next iteration: it's the smaller bite *and* the bigger expressivity unlock. (2) becomes acute only when someone tries to run an unbounded loop, which the current examples don't. ## 2026-05-07 — Iter 9 done: dogfood + `ail run` Course-corrected from the Iter-8 plan. Polymorphism is the bigger expressivity unlock on paper, but I hadn't actually proved that the language was sufficient for "small but real" programs without it. So Iter 9 became a dogfood iteration: write a non-trivial program that exercises everything Iter 1-8 shipped, and use `ail run` / errors / type-checker output as the user would. If something broke, fix it. If nothing broke, document the boundary moved. **`examples/list_map.ail.json`**: ```jsonc type IntList = Nil | Cons Int IntList map_int :: ((Int) -> Int, IntList) -> IntList map_int(f, xs) = match xs { Nil -> Nil Cons(h, t) -> Cons(f(h), map_int(f, t)) } print_list :: (IntList) -> Unit !IO print_list(xs) = match xs { Nil -> () Cons(h, t) -> let _ = do io/print_int(h) in print_list(t) } main = let xs = Cons 1 (Cons 2 (Cons 3 Nil)) in print_list(map_int(\\x. x * 2, xs)) ``` **Result:** nothing broke. Output `2\\n4\\n6\\n`, exit 0. The full pipeline (`ail run`) covers: ADTs with two ctors of different arity; pattern matching with nested `Var` fields; recursion over ADT; closures (with no captures here, so env is null but the closure-pair plumbing still gets exercised); fn-typed parameters in a top-level def; `do io/...` inside a match arm body, with `let _` to sequence two effectful operations; effect propagation through the call chain. This validates Iter 1-8 as a self-contained foundation. **Friction surfaced:** writing the AST by hand is tedious — the JSON for this 4-def module is 200+ lines. That's not surprising (the format is for LLMs, not humans), but it suggests an Iter 10 priority: a richer pretty-print form, or an `ail snippet` helper for common boilerplate (`mk_list_int`, etc.). Not blocking; noted. **`ail run` (Iter 9b):** Builds into a tempdir + execs the binary, exit code passthrough. Saves a `cd && ./bin` step in the dogfood loop. Tiny addition — `Cmd::Build`'s body factored into a shared `build_to` helper. **Architecture self-check:** - *Would I use this language now?* For self-contained Int-typed programs over recursive ADTs: yes. The list_map example is what I would have wanted to write since Iter 6 and bounced off repeatedly. It now compiles and runs without me adapting the source — the language is what its authors said it was, end to end. - *Did I think of everything?* Two cracks observed during the dogfood: - `(Int)` parens around single-param fn-types in pretty-print are visual noise. Cosmetic, can wait. - `let _ = do in ` is the only way to sequence effects today. Working as intended given KISS, but a `;` operator (sequencing) would be cheap polish. - *Consistency:* DESIGN.md CLI block + smoke-test list updated. Iter 8c invariant — "What is not (yet) supported" ≡ truth at end of latest iteration — held; no new pending items. - *KISS:* Iter 9 added 0 LOC of language semantics. All gain came from validating the existing surface and a small CLI helper. **Tests:** 50 green (was 49). New e2e `list_map_doubles_then_prints`. No test for `ail run` itself — `build_and_run` already exercises the equivalent path. **Plan iteration 10:** The dogfood revealed two real-but-not-blocking pain points and one big architectural gap. Candidates, ranked: 1. **Polymorphic let-bindings with monomorphisation at codegen.** Allows `let id = \\x. x in (id 1, id true)` and ultimately `map :: (a -> b) -> List a -> List b`. The ground truth-ier answer for the "would I use it for X?" question, but a non-trivial pipeline change (typechecker→codegen needs to thread instantiation info to the call site). 2. **Sequencing operator `;` and richer effect ergonomics.** A `Term::Seq { lhs, rhs }` (or compile sugar to `Let { name: "_", value: lhs, body: rhs }`) plus a small pretty-print update. Cheap, satisfying. 3. **GC.** Heap reclamation for ADT boxes, lambda envs, closure pairs. Real architecture step. Becomes acute the moment someone writes a long-running loop; the current examples don't. Tentative pick: (2) for the next sprint as a satisfying small polish, then (1) as Iter 11. (3) bides its time until a real program needs it. ## 2026-05-07 — Iter 10 done: Term::Seq sequencing Followed the Iter 9 plan and shipped (2). New AST node `Term::Seq { lhs, rhs }` with serde tag "seq". Semantics: evaluate lhs (which must be Unit), discard the value, return rhs. Effects from both sides accumulate. This is sugar for `let _ = lhs in rhs`, but it's a first-class node because: - The pretty-print renders cleanly (`(seq lhs rhs)` instead of borrowing the `let` form with a discard binding). - Diagnostics are sharper: a non-Unit lhs gets a "type mismatch" error pointing at the seq site, not "binding `_` had type X" at a let site. - Future tooling (effect inference visualisation, dataflow) can treat sequencing as a structural concept instead of a special- cased let. Codegen is trivial: lower lhs (drop SSA), lower rhs (return). Refactored `examples/list_map.ail.json`'s `print_list` to use seq instead of `let _ = ...`. Output unchanged (`2\\n4\\n6\\n`); the JSON shed a few lines and reads more honestly. **Architecture self-check:** - *Would I use this language now?* Same answer as Iter 9 (yes for small but real programs), but the seq node makes IO-heavy recursion read better — closer to "call this effect, then this one" instead of "bind this effect to nothing, then this one". - *Did I break anything?* Hash stability check: existing examples without `Term::Seq` serialise identically; their fn hashes are unchanged. `list_map.ail.json`'s hashes shifted as expected since its body changed. - *KISS:* +30 LOC across AST/pretty/check/codegen/walker. One unit test for the lhs-must-be-Unit rule. The dogfood example proves the e2e path. **Tests:** 51 green (was 50). New `seq_lhs_must_be_unit` unit test in ailang-check. Existing list_map e2e still passes after the refactor. **Plan iteration 11:** Polymorphism, as queued in the Iter 9 plan. Concretely: HM-style unification + let-generalisation in the typechecker, monomorph- isation at codegen time. Touches the typechecker→codegen pipeline. Bigger commit than the recent stretch, will probably need to be phased (typechecker substitution machinery, then codegen specialisation, then docs). ## 2026-05-07 — Iter 11 done: deeper dogfood (insertion sort) Pulled back from polymorphism for one more validation cycle before the architectural step. Polymorphism is a substantial pipeline change (typechecker substitution + codegen monomorphisation) and I wanted one more "small but real" program to confirm the existing foundation holds before disturbing it. `examples/sort.ail.json` — insertion sort over `IntList`: ```jsonc insert :: Int -> IntList -> IntList insert(y, xs) = match xs { Nil -> [y] Cons(h, t) -> if y <= h then Cons(y, Cons(h, t)) else Cons(h, insert(y, t)) } sort :: IntList -> IntList sort(xs) = match xs { Nil -> Nil Cons(h, t) -> insert(h, sort(t)) } print_list :: IntList -> Unit !IO // uses Iter 10 seq main = print_list(sort([3,1,4,1,5,9,2,6,5,3,5])) ``` **Result:** typechecks first try, runs first try, prints `1 1 2 3 3 4 5 5 5 6 9` (each on its own line). 11-element input, correct sorted output. The combination of recursive ADT pattern match + comparison ops + branching + leaf recursion + IO sequencing all worked end to end without the language tripping me up. Iter 10's seq made `print_list` notably cleaner than the `let _ = ...` form would have been. **Architecture self-check:** - *Would I use this language now?* For "small but real" monomorphic programs over Int, Bool, Unit, Str, and ADTs of those: confidently yes. Insertion sort writes out as the textbook recursion, no bookkeeping that the language couldn't do for me. - *Did I think of everything?* The remaining wall is still polymorphism. Sort over `IntList` needs hand-monomorphisation; a generic `sort :: (a -> a -> Bool) -> List a -> List a` is what the language eventually wants. No new architectural cracks surfaced from this dogfood. - *Visualisation:* `ail describe sort.ail.json sort` reads the way I'd expect a sort definition to read, with `IntList` types inline and the recursive call rendered cleanly. - *KISS:* Iter 11 added 0 LOC of language semantics and 1 e2e test. The 250-line JSON for the example is verbose but mechanical — no friction once you accept that the JSON is the surface for LLM authors. **Tests:** 52 green (was 51). New e2e `insertion_sort_orders_list`. Pure addition; existing tests untouched. **Plan iteration 12:** Now polymorphism. Two more dogfood programs would just keep producing the "the language is fine for monomorphic programs" result, which is already established. The real expressivity unlock — and the answer to "would I use it for X?" for X that actually needs generic data — is HM inference + let-generalisation + monomorphisation. Phased plan: 12a. Typechecker: introduce a `Subst` (type variable substitution) and unification. Thread through `synth`. At `let`, generalise syntactic values (lambdas) — no value-restriction subtlety needed yet, the MVP has no mutable refs. 12b. Codegen: at each polymorphic call site, the typechecker records the instantiation. Codegen walks the AST a second time per (def, instantiation) pair and emits a specialised version with the type variables substituted by concrete types in fn signatures. 12c. Docs + a polymorphic `id` test + a generic `map :: (a -> b) -> List a -> List b` rewrite of `list_map.ail.json`. ## 2026-05-07 — Iter 12a/b done: polymorphism reaches the binary Skipped 12c's "polymorphic map" — without parameterised ADTs (which the MVP doesn't have), the rewrite would still be over a concrete `IntList`, defeating the purpose. So 12c becomes lighter: docs + two new examples (`poly_id`, `poly_apply`) that prove polymorphism end-to-end on primitive types and on fn-typed parameters. The big test is whether *I* would use the language now for a poly-flavoured program; the answer below. **12a — typechecker:** `Type::Forall { vars, body }` is now legal at top-level fn types. Implementation is the textbook ML rule: peel the Forall when checking the body (rigid vars go into `Env.rigid_vars` so `check_type_well_formed` accepts them), instantiate fresh metavars at every var-resolution site, unify on every formerly-`expect_eq` edge. The metavar encoding sidesteps an AST schema change: a metavar is just `Type::Var { name: "$m" }`. The `$` prefix can't collide with source identifiers, the JSON layout doesn't shift, and module hashes stay bit-identical (verified: `sum.ail.json` keeps `db33f57cb329935e` / `d9a916a0ed10a3d3`). I considered adding a new `Type::Meta` variant under `#[serde(skip)]` but that would have pulled hashing concerns into serde; the naming convention keeps the AST untouched. `Subst` is a flat `BTreeMap`; `unify` is the standard occurs-check version with effects compared as a set. Constants still reject Forall outright; ADT fields still reject vars. No let-generalisation: lambdas inside fn bodies are checked monomorphically against their declared types — keeps the implementation small and matches DESIGN.md's "top-level types must always be explicitly annotated". **12b — codegen:** Direct calls to a polymorphic def get monomorphised on demand. Each unique (def, instantiation) pair emits a specialised LLVM fn with mangling `@ail____`. Descriptor scheme: `Int → I`, `Bool → B`, `Unit → U`, `Str → S`, ADT `Foo → FFoo`, `Fn(a)→b → Fn___r_`. So `id(42)` and `id(true)` produce `@ail_poly_id_id__I` and `@ail_poly_id_id__B` side by side. Pass 1 of `lower_workspace` now splits fn-typed defs into mono (`module_user_fns`, LLVM-typed FnSig as before) and poly (`module_polymorphic_fns`, full FnDef). A unified `module_def_ail_types` carries AILang types for both, used by the codegen-side type tracker. The hard part was getting AILang types at call sites. The typechecker has them but doesn't hand its annotations down (no TIR yet). I considered three paths: 1. Typechecker sidetable keyed by AST node ids — would need to assign ids deterministically, brittle. 2. Uniform representation (everything passes as ptr/i64) — contradicts CLAUDE.md's "performance is extremely important". 3. Codegen replays the type derivation locally. Picked (3). The trade-off is duplication (`synth_arg_type` mirrors what the typechecker already did), but it's contained to a small recursive walk and uses the same `locals`/`extras` shadowing pattern. Worth it for the MVP — once a TIR stage materialises (it's still on the debt list), the duplication collapses into a single pass. `locals` grew from 3-tuple to 4-tuple `(name, ssa, llvm_type, ail_type)`. Six push sites updated mechanically. Lambda capture metadata grew the same way. `CtorRef` got `ail_fields` so match arm bindings inherit the AILang type. The drain phase iterates until `mono_queue` is empty — specialised bodies can themselves invoke polymorphic defs and queue further entries. `apply_subst_to_term` substitutes rigid vars in `Term::Lam` annotations (the only Term arm carrying types). **Architecture self-check:** - *Would I use this language now?* For monomorphic programs: yes (already established). For polymorphism over primitives and fn-typed parameters: yes — `id` and `apply` write out the way the textbook says they should, with no language-level bookkeeping leaking into the source. The `poly_apply` example was particularly revealing: the closure-pair ABI (Iter 8a) composes cleanly with monomorphisation. Specialised body of `apply__I_I` keeps `f` as a fn-typed local; the existing indirect-call path already handles the lower from there. - *Did I think of everything?* No, two known gaps: 1. **Polymorphic fn passed as a value** (`let f = id in f(42)`) fails in codegen — `resolve_top_level_fn` looks in `module_user_fns` only. Adding this means emitting one closure-pair global per instantiation, possibly via the same drain pass. Defer. 2. **Higher-rank polymorphism** (`apply(id, 42)`) trips `unify_for_subst` which doesn't handle Forall on the param side. Real higher-rank polymorphism is a substantial step and not on the near horizon — deferred to a later iter. - *Visualisation:* `ail manifest poly_id.ail.json` now shows `forall a. (a) -> a` correctly. The pretty-printer carried `Type::Forall` rendering since Iter 1; nothing to do. - *KISS:* +1 typechecker file edit (~430 LOC inserted, mostly Subst+unify+four tests), +1 codegen extension (~600 LOC inserted, mostly the drain path + helpers + locals widening). Two new examples, two new e2e tests. Could be smaller if I bit the bullet on TIR; not yet worth the upfront cost. **Tests:** 58/58 (was 56/56). Added 4 typechecker unit tests in 12a, 2 e2e tests in 12b. Hash invariant holds. **Plan iteration 13 (queued, not started):** The natural next step depends on what I want to use the language for. Two candidates, in order of expected payoff: 13a. **Parameterised ADTs** — `List a`, `Maybe a`, etc. Without these, polymorphism is half-useful: a generic `map` still can't transform an `IntList` into a `BoolList`. ADT defs would gain a `vars: Vec` field; ctor field types could mention them; codegen monomorphises ADT instances just like fns. This is the bigger expressivity unlock. 13b. **GC or arena** — every ADT box, lambda env, and closure pair currently leaks. For sort over an 11-element list, fine. For anything longer-running, required. The current lifetime model is "leak"; the right MVP is probably bumpalloc per top-level fn invocation. Could be done before parameterised ADTs but doesn't unlock new examples. Leaning 13a — it's the more interesting architectural step and makes the "polymorphic map" rewrite from the original 12c plan finally meaningful. ## 2026-05-07 — Iter 13 done: parameterised ADTs reach the binary **Why now.** End of Iter 12 left polymorphism half-useful: `id` and `apply` worked, but every container was monomorphic (`IntList`, `Maybe_Int`). A generic `map :: forall a b. ((a) -> b, List a) -> List b` was unwritable. 13 lifts that. **Three commits:** - `0782622` 13a — schema (`TypeDef.vars`, `Type::Con.args`) + checker (substitution at ctor + match + arity validation in `check_fn`). - `1631f60` 13b — codegen: per-use-site substitution of LLVM field types in `lower_ctor` and `lower_match`. No mono-queue for types — ctor code was already inlined at every use site, so 13b only had to thread substitution through, not invent a symbol scheme. `synth_arg_type` for `Term::Ctor` now returns concrete type-args, and `llvm_type(Type::Var)` is a hard error instead of a silent `ptr` fallback (the latter was flagged by the architect review and is the most defensive single change in 13). - `` 13c — DESIGN.md flipped (parameterised ADTs out of the gap list, into the supported list); two new example lines. **Hash invariant.** Both new fields are `#[serde(default, skip_serializing_if = "Vec::is_empty")]`. A new regression test in `crates/ailang-core/src/hash.rs` deserialises the actual `examples/sum.ail.json` and `examples/list.ail.json` from disk and asserts `db33f57cb329935e` and `b082192bd0c99202` — the recorded pre-13a hashes. It's deliberately phrased against the on-disk JSON rather than reconstructed code, so the test fails if anyone resaves the examples in a way that drifts the canonical bytes. **Architect-flagged debt I deliberately did NOT touch in 13:** - `is_static_callee` returns true for poly fns but `resolve_top_level_fn` only consults `module_user_fns`. A poly fn used as a value (`let f = id in f(42)`) passes the static check then surfaces as `UnknownVar`. Would need one closure-pair global per instantiation. Out of 13 scope; same hole that was queued at the end of Iter 12. - Triple source of truth for builtins (`builtins::install`, `builtins::list`, `codegen::builtin_ail_type` / `builtin_effect_op_ret`). Every new operator costs three edits. Low interest today, escalates with every effect op. Worth a future tidy iter — not blocking expressivity. - `synth_arg_type` for `Term::If` returns `synth(then)` only; for `Term::Match`, the first arm. Masked today by the typechecker having already unified, but it's the kind of duplication that decays. Same fundamental cost as the absence of a TIR. **Architecture self-check.** - *Would I use this language now?* For polymorphism over primitives, fn-typed values, AND parameterised containers — yes. The `box.ail.json` and `maybe_int.ail.json` examples read like the textbook says they should. No type-arg bookkeeping leaks into the source. - *KISS.* 13b was much smaller than I feared at the start of the design phase: ~150 LOC in codegen, no new structures, no mono-queue. The reason: ADT ctor code is already inlined. The architect's recommendation to *not* mutate `ctor_index` but derive `CtorRef` per use site was the right call — preserved the static template, made the substitution local. - *Did I think of everything?* Two known gaps remain. **(1)** Polymorphic ADTs as the type-arg of a polymorphic fn — works today because `unify_for_subst` recurses through `Type::Con.args` (added in 13a). **(2)** A polymorphic fn taking a polymorphic ADT and returning a different parameterised ADT (`map : forall a b. ((a)->b, List a) -> List b`) — should also work, but I haven't dogfooded it yet because `List a`-as-a-rewrite-of-`list_map` would need the schema bumps elsewhere (paramaterised list builder). Queued for Iter 14. - *Visualisation.* `ail manifest examples/box.ail.json` shows `type Box :: forall a. MkBox(a)` and `fn unbox :: forall a. (Box) -> a`. The pretty-printer picked up `args` and `vars` cleanly (Iter 13a). **Tests:** 64/64 (was 58/58). Added: 1 hash-stability regression (13a), 3 checker unit tests for parameterised ADTs (13a), 2 e2e tests over `box.ail.json` and `maybe_int.ail.json` (13b). **Process note (orchestration).** First iter where I worked strictly through the agents in `/agents/`: `ailang-architect` ran a drift review on HEAD before 13b started; `ailang-implementer` got a fixed brief that incorporated the architect's three recommendations (don't mutate `ctor_index`, fix `synth_arg_type` for `Term::Ctor`, harden `llvm_type`); 13c (this) is the orchestrator's own work. The role split landed in `3df943d` after I caught myself doing implementer work on 13a directly. The agents pay off in proportion to iter size — for 13b they were clearly worth the round-trip; for 13a's checker work, marginal. **Plan iteration 14 (queued, not started):** Two candidates, in order of expected payoff: 14a. **Polymorphic `List a` rewrite of `list_map`.** Replaces `IntList` with `List a`, rewrites `list_map` to return `List b`, and lets the polymorphic-map version be the dogfood smoke test. Pure exercise — should fall out of 13b — but worth the dogfood beat. Also: `Maybe a` used in a non-trivial fn (e.g. `find : forall a. ((a) -> Bool, List a) -> Maybe a`). 14b. **GC or arena.** Same pitch as before: every ADT box, lambda env, closure pair leaks. For `box.ail.json` and `maybe_int.ail.json`, fine. For anything that allocates in a loop, required. Bumpalloc per top-level fn invocation is the natural MVP. 14c. **Poly fn as value.** Closes the asymmetry the architect flagged; gates `let f = id in f(42)`. One closure-pair global per instantiation, emitted via the same mono-queue drain path. Smaller surface than 14a/b. Leaning 14a — the dogfood payoff for one iter of polish is high, and `Maybe`-in-a-real-fn is a missing piece I haven't exercised yet. 14b stays second; 14c is a candidate if I want a small palate cleanser. --- ## Iter 13d — rustdoc polish for `ailang-core` + new `ailang-docwriter` agent User triggered: ran `cargo doc --open` for fun and reported that the rendered docs were thin — crate headers existed, but `pub` items had no `///` strings, there were no `# Examples` sections, and intra-doc links were missing. Internal references showed up as prose ("see Builtins") rather than as clickable links. Two stale `[Builtins]` and `[code]` warnings had been bleeding into every `cargo doc` invocation since Iter 6 or so. Recurring task → new agent. Wrote `agents/ailang-docwriter.md` with a tight mandate: rustdoc only, no API changes, no edits in `docs/` or `agents/`, three verification gates (rustdoc clean, build green, tests green incl. doctests). Updated `agents/README.md`. Updated DESIGN.md item 6 of "Verification and correctness" to make rustdoc cleanliness a project-wide invariant rather than an iter-local cleanup. First mission: `ailang-core` only. The foundation crate every other crate depends on — biggest reader-leverage per diff. The agent rewrote the crate root so a newcomer learns: what `core` owns, where it sits in the pipeline (`core` → `check` → `codegen` → `ail`), the central invariant (canonical JSON is deterministic, hashes content-addressed, schema = `ailang/v0`), and the entry points. Module roots in `ast.rs`, `canonical.rs`, `hash.rs`, `pretty.rs`, `workspace.rs` got the same treatment. Every `pub` struct / enum / variant / fn / const got a `///` string. The Iter-13a additions (`TypeDef.vars`, `Type::Con.args`) got an explicit backwards-compat note that points back to the hash-stability regression test. `# Examples` blocks landed where they shorten understanding (`canonical::to_bytes`, `def_hash`, `Workspace`), all marked `ignore` so the workspace doctest run stays cheap (3 ignored, 0 run, 0 failed). The two stale broken-link warnings in `ailang-check` got prose-only fixes — out-of-scope for this iter conceptually, but a one-line fix per file means rustdoc is now globally clean. **Findings reported by the docwriter** (judgement deferred to me; none made it into the diff): - `Term::Lam.param_tys` (JSON: `paramTypes`) is positionally paired with `Term::Lam.params: Vec`. Naming hints at the convention but a cold reader has to deduce it. **Not fixing**: renaming would touch the schema, breaks every hash. The `///` string makes the convention explicit, which is enough. - `Type::Var` is overloaded: source-level rigid vars and checker metavars (`$m`) share the same variant. A reader of `core` alone sees no hint of the metavar half — it's documented in `ailang-check`'s lib doc instead. **Not fixing**: splitting the variant would balloon the schema and invalidate every hash. Acceptable as long as `check`'s lib doc explains it (it does, post-13d). - `Def::Type(TypeDef)` versus the type-expression enum `Type` in the same module: name collision is real but unavoidable without renaming `Type` (which would touch every crate). The `///` strings now disambiguate at point of contact. **Process note (orchestration).** Second iter where I worked strictly through agents (after 13b). The docwriter brief was written from the diagnostic in this conversation, not from a DESIGN-doc design pass — there was no architecture decision to make, just a discipline gap to close. That's the right shape for a docwriter: low-judgement, repeatable, runs after every iter that touches public surface. Cost ≈ 25 tool uses for ~390 LOC of doc additions across 9 files; smaller-grain than `ailang-implementer` runs typically are. **Tests:** 64/64 unit + e2e (unchanged), 3 ignored doctests (new). `cargo doc --no-deps`: 0 warnings (was 2). `cargo build --workspace` green. `cargo test --workspace` green. **Plan iteration 13e/13f (queued, not started).** Two natural follow-ups for the docwriter: 13e. **`ailang-check` rustdoc**: type-checker is the next biggest crate by `pub`-surface and the most algorithmically dense. Rigid/metavar split, `Forall` instantiation, the match-arm exhaustiveness logic, the Iter-13a substitution machinery — all of it benefits more from prose explanation than `core` did. 13f. **`ailang-codegen` + `ail` CLI rustdoc**: codegen is dense but mechanical (mangling, ABI, block tracking); the CLI is mostly clap derive macros. Lower payoff per LOC of doc than 13e but rounds out the warning-free invariant across the whole workspace. After 13d there's no urgency on 13e/f — `cargo doc` is already warning-free. They're "polish iterations to be slotted in between feature iters when context budget is short". 14a (`List a` rewrite) remains the next-feature default. ## Iter 13e — rustdoc polish for `ailang-check` Second docwriter mission. Same mandate as 13d, applied to the typechecker crate. `lib.rs` (1922 LOC) had a strong crate root already — covers HM-with-effects, top-level forall, the rigid-vs-metavar split, the `$m` encoding — but its `pub` surface (the `Env` struct, `CheckError` + 24 variants, `CheckedModule`, `CtorRef`, the `check_module` entry point) was mostly undocumented. `builtins.rs` had a one-line module header and zero `///` strings on `EffectOpSig` or `install`. `diagnostic.rs` had a strong module header (lists every stable diagnostic code) but `Diagnostic`, `Severity`, and the construction helpers were undocumented. Agent added 188 LOC of pure rustdoc across the three files; no non-doc lines changed (verified by filtering the diff). Each `CheckError` variant now carries the AST term/type that triggers it and the stable kebab-case `code` it maps to. `Env` fields (`globals`, `effect_ops`, `types`, `module_globals`, `current_module`) got individual `///` strings that name the invariants — most importantly that `module_globals` includes the current module, which the agent flagged as undocumented at field level (now fixed). Crate-root prose got an upgraded intra-doc link to `[`check_module`]`; `super::` reference in `diagnostic.rs` rewritten to `crate::` (cosmetic, but the canonical form). **Findings reported** (judgement deferred to me): - `Env` is `pub` with all-`pub` fields but `Env::new` is private and there's no public builder — external callers can only construct via field-by-field literal, which is fragile if a field is added later. **Not fixing**: changing this is an API decision, not a doc one. Worth raising next time we touch the crate's public surface deliberately. - `CheckError::CtorArity` and `CheckError::ArityMismatch` both serialize to the public diagnostic code `arity-mismatch`. The `///` strings now flag the collision per-variant; tooling that consumes the diagnostic JSON sees only the merged code and that's intentional from the iter-5b vintage. **Not fixing.** - `Diagnostic` and `Severity` are reachable both via the crate re-export and via `crate::diagnostic::*` because the `diagnostic` module is itself `pub`. Rustdoc renders both pages; harmless but slightly noisy. **Not fixing**: the re-export is the documented entry point and we don't want to hide the module. **Process note.** Same shape as 13d: I wrote a brief naming the deficiencies (numbers of pub items, which files had thin roots), the agent did the doc additions inside its mandate, the orchestrator-side work was authoring DESIGN/JOURNAL and verifying the diff. Cost ≈ 32 tool uses for 188 LOC of doc across 3 files — denser than 13d (more sentences per pub item because the typechecker invariants need explicit articulation), but the per-LOC payoff for a future reader is also higher. **Tests:** 64/64 unit + e2e (unchanged), 3 ignored doctests (unchanged). `cargo doc --no-deps`: 0 warnings (was 0; the agent introduced 4 transient broken-link warnings during the work and resolved all of them before reporting done). `cargo build --workspace` green. `cargo test --workspace` green. **Plan 13f / 14a unchanged.** 13f (`ailang-codegen` + `ail` CLI) is the natural next polish iter; 14a (`List a` rewrite of `list_map`) remains the next-feature default. Auto-mode is on, so I'll continue into 13f directly unless context budget pressures a switch. ## Iter 13f — rustdoc polish for `ailang-codegen` + `ail` CLI Combined docwriter mission. Codegen has a small public surface (only 3 top-level `pub` items: `CodegenError` enum + 7 variants, `emit_ir`, `lower_workspace`); the CLI is a binary with zero `pub` items, so the only useful rustdoc is the module header. One agent run, both files. What landed (148 LOC of doc additions, no non-doc lines changed, verified by filtering the diff): - `ailang-codegen` crate root got intra-doc-link upgrades (`[`emit_ir`]`, `[`lower_workspace`]`, `[`CodegenError::MissingEntryMain`]`) and a precondition sentence — both entry points assume their input has already passed the typechecker; codegen does not call the checker itself. - Every `CodegenError` variant got a `///` string naming the AST term/condition that triggers it. Same shape as `CheckError` post-13e, so the two error enums now read similarly and a reader can grep across them. `Internal` is flagged in its doc as a catch-all that covers ~30 invariant-violation sites. - `emit_ir` and `lower_workspace` now make the single-vs-multi-module split explicit and cross-link to each other. - `ail/main.rs` module header expanded from a 5-line stub to a full subcommand list with one-liners (`manifest`, `render`, `describe`, `deps`, `check`, `emit-ir`, `build`, `run`, `builtins`, `diff`, `workspace`), the `clang`-on-PATH prerequisite for `build`/`run`, the design-intent paragraph about each subcommand being narrowly scoped for LLM consumption (already partly there), and an explicit "no `pub` items, `--help` text comes from clap" note for anyone who lands here from rustdoc. **Findings reported** (judgement deferred to me): - `CodegenError::Internal(String)` is a single opaque catch-all for ~30 distinct invariant-violation sites (mono-queue desync, ctor-index miss, lambda-env shape, ...). Tests can only substring-match on it. **Not fixing**: splitting is a test-ergonomics decision, not a doc one. Worth raising the next time codegen tests get a serious rewrite. - `emit_ir` synthesises an internal `Workspace` with `root_dir = "."`. No codegen path reads `root_dir` today, so this is harmless; if a future feature reaches `root_dir` from codegen, the assumption surfaces. **Not fixing**: the agent flagged it correctly as "would change behaviour, out of scope". **Process note: brief drift caught by the agent.** I told the docwriter the CLI had nine subcommands; it found eleven (`Deps` and `Diff` were missing from my brief, which was written off a `head -40` of the source). Agent silently corrected and flagged the drift in its findings. Useful counter-pressure to the orchestrator pattern: my survey was sloppy and the agent did not propagate the sloppiness into the doc. This is one of the things sub-agents are good at and why I keep delegating even on small jobs. **Tests:** 64/64 unit + e2e (unchanged), 3 ignored doctests (unchanged). `cargo doc --no-deps`: 0 warnings. Also verified under `RUSTDOCFLAGS='-D rustdoc::broken_intra_doc_links'` per agent report. `cargo build --workspace` green. `cargo test --workspace` green. **Workspace-wide rustdoc invariant achieved.** All four crates (`ailang-core`, `ailang-check`, `ailang-codegen`, `ail`) now have: - crate-root `//!` that names ownership, position in pipeline, and entry points; - module-root `//!` on every file with non-trivial content; - `///` on every public item (struct, enum, variant, fn, field, const) that names the contract, not just the type; - intra-doc links wherever prose previously referred to another item by name. DESIGN.md item 6 ("rustdoc cleanliness") is now load-bearing across the whole workspace, not just `core`. The `ailang-docwriter` agent's job from here is **maintenance**: run after any iter that adds public surface, not full sweeps. **Next.** 14a is now unblocked: write a polymorphic `list_map` that uses `List a` (Iter-13a parameterised ADTs) and `Maybe a`, then extend an existing demo program (`hello_print` or one of the dogfood sources) to call it end-to-end. That exercises the parameterised-ADT pipeline through type-check, codegen, and runtime — the missing piece in 13a/b/c was that the feature shipped but no real program used it. If context budget at the start of 14a is tight, alternative is 14b (GC/arena scaffolding) or 14c (poly fn as value); both have real design questions that need an orchestrator design pass first, so 14a stays the default. ## Iter 14a — polymorphic `List a` end-to-end + monomorphisation bug fixed The dogfood payoff for parameterised ADTs (13a/b/c). Prior to this iter, the only programs exercising the feature were `box.ail.json` (single `MkBox(42)` round-trip) and `maybe_int.ail.json` (single `or_else` call). That is a thin slice — neither program builds a recursive parameterised ADT nor calls a polymorphic higher-order fn. 14a closes that gap with `data List a` + `map : forall a b. ((a) -> b, List) -> List` recursive, then prints the result. Three-agent run (tester → debugger → no implementer needed): 1. **Tester** wrote `examples/list_map_poly.ail.json` (5 defs: `List`, `inc`, `map`, `print_list`, `main`) and a new e2e test `list_map_poly_inc_then_prints` that asserts stdout `["2", "3", "4"]`. Typecheck passed, build crashed: `internal: monomorphisation: var \`a\` bound to two distinct types`. Tester correctly stopped — fixture encodes the contract; bug is in the compiler — and reported with a hypothesis (recursion / shared substitution slot in `map`). 2. **Debugger** refuted the hypothesis with a non-recursive repro (`Cons(7, Nil)` triggers the same crash without `map` in the picture at all). Real cause was much smaller: in `synth_arg_type` (codegen, ~line 2087) for `Term::Ctor`, any type var of the parent ADT that the ctor's args couldn't pin was filled with `Type::unit()` as a placeholder. For nullary ctors of a parameterised ADT (`Nil : List`, `None : Maybe`) that placeholder leaked upward. Inside a parent like `Cons(Int, Nil) : List`, `unify_for_subst` would walk `cref.ail_fields = [Var{a}, Con{List,[Var{a}]}]` against `[Int, Con{List,[Unit]}]`, bind `a = Int` from the head, then collide with `a = Unit` from the tail. The recursive `map` fixture surfaced it because it is the first program to nest a nullary ctor of a parameterised ADT inside a parent ctor — the existing 13b regressions never did. The tester's hypothesis was reasonable from the symptom but wrong on mechanism; refuting it via a smaller repro is exactly the discipline the debugger role is for. 3. **Fix** (`crates/ailang-codegen/src/lib.rs`, +30/-9 LOC, no new variant, no API change): - Replace `Type::unit()` placeholder with a synth-only wildcard `Type::Var { name: "$u" }`. The `$u` prefix is a reserved-namespace convention that mirrors the checker's `$m` for instantiation metavars — same trick (source-level identifiers can't start with `$`), same goal (extra semantics without schema change). - `unify_for_subst` short-circuits on a `$u`-prefixed **arg-side** var: accept without binding, let a sibling arg pin the type var instead. Param-side semantics untouched. `derive_substitution`'s "var not pinned" check still fires for genuinely under-determined calls (those would have failed typechecking, so codegen never sees them, but the safety net stands). **Tests:** 25/25 e2e (was 24, +1 new poly test). All five named regressions stayed green: `list_map_doubles_then_prints`, `parameterised_box_round_trip`, `parameterised_maybe_match`, `polymorphic_id_at_int_and_bool`, `polymorphic_apply_with_fn_param`. Insertion sort still green. `cargo doc --no-deps`: 0 warnings (workspace invariant from 13d/e/f preserved). `cargo build --workspace` green. **Process note: tester→debugger→done in one iter.** No implementer dispatch needed — the debugger's mandate covers "propose **and apply** a minimally invasive fix" once the diagnosis is solid. 30 LOC across two sites in one file is inside the role's scope (the role doc says "stop and report" only on >50 LOC across multiple files). The orchestrator-side work was the design (the source program), the scoped briefs, and verification. This is the cleanest agent-flow shape so far: each agent did exactly its job, the tester's wrong hypothesis didn't propagate because the debugger tested it, and the orchestrator never wrote compiler code. **Findings flagged** (judgement deferred, not fixed): - `CodegenError::Internal` (the catch-all string variant the docwriter flagged in 13f) is now used by one more invariant — the `$u` short-circuit could in principle be reached by a malformed input; it's currently silent because typecheck rejects under-determined calls upstream. Worth splitting into typed variants the next time codegen tests get a real rewrite. (Same finding as 13f, now reinforced by another use site.) - The `$u` / `$m` reserved-prefix convention (`$u` for codegen synth wildcards, `$m` for checker metavars) is undocumented as a project-wide naming rule. Two prefixes is fine; if a third appears, this should be promoted to a DESIGN.md note. **Next.** Parameterised ADTs are now genuinely usable for real programs. The standard library starter set (an `examples/std_*` series with `List`, `Maybe`, `Either`, basic combinators `length`, `filter`, `fold`, `concat`) becomes worthwhile in a way it wasn't pre-14a — that's a plausible 14b alternative, smaller than GC/arena, and would itself surface more dogfood bugs. The original 14b (GC/arena) and 14c (poly fn as value) remain on the queue but have not had a design pass. ## Iter 14b — design pass for the authoring surface User redirected the project at the iter boundary. I had been about to write a stdlib in JSON-AST form; user pushed back: do I really program *best* in JSON, given the language is supposed to be the one I'm most accurate in? Honest answer was no — JSON was rationalisation. The constraint added in this iter: > "Die Syntax sollte gut formalisierbar sein, damit man auch > einem fremden LLM eine Spec geben kann, die es dann fehlerfrei > befolgt." That single sentence ruled out about half the design space: anything with operator precedence (precedence is the #1 thing an LLM gets wrong when handed a spec, because it requires building a parse-tree mental model rather than just following production rules), anything with semantic indentation, anything with maximal-munch lexing or context-sensitive reductions. What remains is roughly: S-expressions, or dialects close to S-expressions. Decision 6 in DESIGN.md captures the constraints in priority order, sketches three candidate forms (A: tagged S-expression, B: indented record-style, C: pretty-printer-as- source), and picks (A) as the first attempt with explicit rollback to (C) if (A) hurts authoring more than it helps. **Key shape of (A):** every AST node has a unique head keyword. No case-disambiguation rule (no "capitalised head means ctor"). Bare atoms in positional slots get their sort from the parent slot (inside `(con NAME args...)` second-and-later positions are types; inside `(app HEAD args...)` first position is a term; etc.). To construct a value with a ctor, write `(term-ctor TypeName CtorName args...)` explicitly. To match, `(pat-ctor CtorName fields...)`. The capitalisation/case of an identifier carries no semantic weight to the parser. This rules out the silent-error class "I forgot to capitalise `Cons` and it parsed as a function call" — which I had been relying on case-conventions to prevent in the JSON form too, but only by hand-discipline. With explicit tags the discipline becomes a parse rule. **Empirical test (this iter).** Hand-encoded three examples in form (A) as `examples/*.ailx`: ``` hello.ailx — 5 LOC (was JSON: 36 pretty / 21 canonical) box.ailx — 25 LOC (was JSON: 160 pretty / 88 canonical) list_map_poly.ailx — 50 LOC (was JSON: 394 pretty / 230 canonical) ``` Roughly 4–8× line reduction, ~4× character reduction. Bigger gains on bigger programs (overhead is proportional to AST depth, not to program size, so the form scales well). All three mapped unambiguously to AST nodes by inspection — no ambiguities surfaced during writing that the spec didn't already cover. Two small lex/grammar issues I discovered while writing and folded back into DESIGN.md before committing: 1. **Operator idents** like `+`, `==`, `<=`. Initial spec had a word-shaped `[A-Za-z_]...` regex; that excluded operators. Fix: lexer recognises only `(`/`)`/whitespace as delimiters. Every other maximal token is classified by first character (digit ⇒ integer, `"` ⇒ string, else ⇒ ident). `+`, `42`, `io/print_int`, `==` all become single ident or integer tokens with no special rule. 2. **Bool literals.** Bare `true`/`false` are reserved in term context; outside term context they would be ill-formed anyway. Unit is explicit: `(lit-unit)`. **Files touched:** - `docs/DESIGN.md` — Decision 6 added (~140 lines), with constraints, three candidates, first-choice rationale, and an implementation outline for Iter 14c. - `examples/hello.ailx`, `examples/box.ailx`, `examples/list_map_poly.ailx` — three design exhibits. Not parseable yet (header comment says so). - `docs/JOURNAL.md` — this entry. **Tests:** None new. Existing 25/25 e2e + 3 ignored doctests unchanged (this iter is paper, not code). `cargo doc --no-deps` 0 warnings (workspace invariant from 13d/e/f preserved). **Process note: orchestration with explicit licence to be wrong.** User said "du darfst auch ausprobieren und dich irren (und es dann rückgängig machen). Wir wollen das beste Design für den propagierten Zweck." That changes the cost model for design iteration: I should optimise for *information per iter*, not for *correctness on first commit*. So I committed form (A) as a working hypothesis with a documented rollback path to form (C), rather than design-by-committee until I was sure. **Plan 14c (next).** 1. New crate `ailang-surface` with a small PEG parser → existing `ailang-core::ast` types. No new AST nodes. 2. Round-trip test gate: every existing `examples/*.ail.json` gets a sibling `*.ailx` written by hand or by an AST→surface emitter; the test parses the surface, canonises to JSON, and asserts hash-equivalence to the original. If a single fixture loses its hash, the form does not ship. 3. CLI: `ail parse -o `. Symmetric to existing `ail render`. 4. If round-trip works for all current fixtures, mark form (A) confirmed and start the stdlib in `.ailx` directly. If it fails, document why in JOURNAL and try (C). **Plan 14d (after 14c).** First stdlib module: `std_list.ailx` with `length`, `filter`, `fold`, `concat`, `reverse`, `head`, `tail`. Each combinator is a fresh test vector for codegen and for the still-young parameterised-ADT pipeline. Written in the new surface from day one — no JSON authoring of stdlib. ## Iter 14c — `ailang-surface` parser + pretty-printer ships Implements Decision 6's form (A). New crate `ailang-surface` (~1843 LOC across `lex.rs`, `parse.rs`, `print.rs`, lib root, plus tests) ships as a strictly additive producer of `ailang-core::ast::Module` values. `ailang-check` and `ailang-codegen` were not modified — projection-agnostic, as the architectural pin requires. **Implementer dispatch went clean.** Brief gave the EBNF, the fixtures to round-trip, the lexer rule, and the architectural constraints. Implementer hand-wrote a recursive-descent parser (one Rust fn per EBNF production), a deterministic pretty- printer, an integration test that runs the round-trip gate on every fixture, and the `ail parse` CLI subcommand. No parser-combinator dependency. No AST-shape changes. **Two AST-driven form refinements** that were not in the 14b sketch (both folded into DESIGN.md Decision 6 by the implementer before commit): 1. `lam-term` had to carry `param_tys`, `ret_ty`, and `effects` because the AST's `Term::Lam` stores parallel typed-param data. Production became `(lam (params (typed x Int) ...) (ret T) (effects ...) (body ...))`. Same shape-style as the rest of the form; no new lex rules. 2. `import-clause` had to admit `Option` aliases. Production became `(import name (as alias)?)` with `as` as a bare ident token. No new lex rules. The 14b 30-production budget held: implementer reports ~28 named productions in the parser. Constraint 1 (formalisable for foreign LLM) intact. **Verification gates, all green:** - `cargo build --workspace`: 0 warnings, finished. - `cargo test --workspace`: 76 tests pass (was 64; +9 unit tests in `ailang-surface`, +2 integration tests in `tests/round_trip.rs`, +1 e2e regression preserved). All 17 `examples/*.ail.json` fixtures round-trip byte-identical through `print → parse → canonical JSON`; 3 hand-written `.ailx` exhibits parse to canonical JSON byte-identical to their `.ail.json` siblings. - `cargo doc --no-deps`: 0 warnings (workspace invariant from 13d/e/f preserved; new crate's rustdoc landed correctly with crate-root `//!` plus all `pub` items documented). **Manual smoke test (orchestrator-side after agent reported done):** `ail parse -o ` followed by `ail run ` for all three exhibits: ``` hello.ailx → "Hello, AILang." box.ailx → "42" list_map_poly.ailx → "2\n3\n4" ``` End-to-end pipeline form (A) → AST → typecheck → codegen → clang → binary works on all three. **Important non-issue.** `examples/*.ail.json` fixtures on disk are hand-formatted JSON with non-canonical key order; the parser produces canonical (lex-sorted) JSON. Diff at the file-byte level is not zero. Diff at the canonical-byte level is zero — which is the only thing that matters per Decision 1, since hashing uses canonical form. The round-trip test gates on canonical bytes, not file bytes. This is correct behaviour; flagged here so a future reader who runs `diff` doesn't think the surface is broken. **Files created:** - `crates/ailang-surface/Cargo.toml` - `crates/ailang-surface/src/lib.rs` (~40 LOC, rustdoc heavy) - `crates/ailang-surface/src/lex.rs` (~264 LOC) - `crates/ailang-surface/src/parse.rs` (~1041 LOC, one fn per production) - `crates/ailang-surface/src/print.rs` (~371 LOC) - `crates/ailang-surface/tests/round_trip.rs` (~128 LOC) **Files modified:** - `Cargo.toml` (workspace) — `ailang-surface` member + workspace dep. - `Cargo.lock` — refresh. - `crates/ail/Cargo.toml` — `ailang-surface` dep. - `crates/ail/src/main.rs` — new `Parse { path, output }` subcommand (~36 LOC). - `docs/DESIGN.md` — form refinements appendix to Decision 6. - `examples/list_map_poly.ailx` — implementer added doc strings to match the JSON original (was a 14b design exhibit, not byte-aligned). **Known debt:** none reported. `ailang-check` / `ailang-codegen` untouched per the architectural pin. **Plan 14d (next, queued).** With form (A) now ergonomic and round-trip-verified, the std-lib path opens up. First target: `examples/std/std_list.ailx` with `length`, `filter`, `fold` (left and right), `concat`, `reverse`, `head`, `tail`. Each combinator a fresh test vector for the parameterised-ADT pipeline (which 14a opened end-to- end). Authored in form (A); the resulting `.ail.json` is what tests consume. If 14d surfaces more codegen bugs in the parameterised-ADT path (it likely will — 14a found one already), debugger handles them inline. The 14b/c form-A hypothesis has held under the empirical test of round-tripping every existing fixture. The documented rollback to form (C) is now off the table for this iter cycle, though the architectural pin keeps it open for future replacement of `ailang-surface` should the form prove inadequate at stdlib scale. ## Language-completion sequence (14d → 14f, then stdlib in 15a) User redirected at the 14d boundary: write the language to "finished" before starting on a stdlib. Reasoning: authoring a stdlib in an unfinished language wastes work — each gap discovered later forces a rewrite of code already written. The user also confirmed: **no schema version bump needed**. AILang has exactly one consumer (me), so version ceremony for compatibility management is pure overhead. Edit AST and fixtures in place; pin new hashes where the hash regression test demands it. Updated planning sequence: - **14d** — remove `Term::If` redundancy. Pure subtraction. - **14e** — explicit tail-call annotation (`tail` flag on `Term::App`/`Term::Do`, `musttail` in codegen, tail-position verifier in checker). - **14f** — memory management. Currently every ADT allocation leaks. Likely Boehm conservative GC (`GC_malloc` + `-lgc`) for minimum surface change; design pass first. - **15a** — first stdlib module (`std_list`). Deferred (not stdlib-blocking; can land later without rewriting code that already exists): records/tuples (use ADT pairs), nested patterns (use pyramid `match`), local recursive `let` (hoist to top level). ## Iter 14d — `Term::If` removed Subtraction iter. `Term::If { cond, then, else_ }` was semantically a subset of `Term::Match` on `Bool`. CLAUDE.md forbids redundancies; two AST nodes for the same operation was an authoring decision with no semantic content and a duplicate codegen path. The migration shape was the canonical one named in the brief: ``` (if c a b) → (match c (case (lit-bool true) a) (case _ b)) ``` Wildcard arm satisfies the existing `primitive-needs-wildcard` rule. A future iter may upgrade exhaustiveness to recognise `true`+`false` as covering Bool without a wildcard, but the wildcard form works with the current checker and that was enough for 14d. **Implementer dispatch went clean with one documented deviation.** Removing the `Term::If` codegen path was not sufficient on its own: the existing match codegen rejects `i1` (Bool) scrutinees and `Pattern::Lit` patterns — both of which the migration shape requires. The implementer added a tightly-scoped `lower_bool_match` helper (~95 LOC) that handles **only** the two-arm Bool migration shape (`(lit-bool true) -> A | _ -> B` or its mirror), errors on anything else, and emits the same `br i1`/phi IR the old `Term::If` path emitted. No generalisation of the ADT-match codegen. The deviation was the right call. **Lesson for future subtraction iters**: when removing a specialised AST node, the codegen for the migration target may need a small extension. Pre-emptively scope this in the brief next time. **Diff size**: 13 files, +286/-221 LOC. Net +65 LOC across the workspace, but the AST got smaller (one variant gone), the form-(A) grammar got smaller (one production gone), and the typechecker got smaller (one branch gone). Codegen got slightly larger because of the bool-match helper, but the alternative was reusing the existing match path and generalising it — which would have been a bigger and riskier change. **Hash deltas** (intentional, per Decision 7): | def | before | after | |---|---|---| | `sum.sum` | `db33f57cb329935e` | `7f5fe7f72c63a9fd` | | `sort.insert` | `697fcb9f30f8633a` | `07ff6ee7db17565d` | | `max3.max` | `65c45d6a45dd0a72` | `2aa1576f3fbf5b3d` | | `max3.max3` | `624b14429bf302f5` | `c452ec2e36c0af27` | Untouched defs (e.g. `sum.main`, all `sort.*` except `insert`, `sort.IntList`, `sort.print_list`, `max3.main`) keep bit-identical hashes. That's the canary that the canonical-JSON byte format was not perturbed — only the migrated bodies changed identity. **Verification**: 76/76 tests green (unchanged count; no new tests in this iter, by design — subtraction). Manual smoke: `sum` → `55`, `max3` → `17`, `sort` → ordered list. Identical to pre-migration stdout for all three. `cargo doc --no-deps` 0 warnings. **Tail-call survey from the implementer (gold finding, informs 14e).** While reading the migrated fixtures the implementer surveyed tail positions. Result: - `print_list` (in both `sort.ail.json` and `list_map_poly.ail.json`): the recursive call is the rhs of a `seq` which is the body of a match arm — **already in tail position**. TCO would convert these to actual loops. - `main` chains: the outer call is in tail position; inner calls are not. - `insert`, `sort`, `map`: the recursive calls are **arguments to a `Cons` ctor construction** (e.g. `Cons (f h) (map f t)`). NOT in tail position. Constructor-blocking is the standard ML/Haskell case where TCO does not apply without a CPS transform or an accumulator-form rewrite. **Implications for 14e.** Adding a `tail` flag to `Term::App`/`Do` will work for `print_list`-style recursions and for terminal call chains, but **will not** help the `map`/`sort`/`insert`-style ctor-blocked recursions. Those need either an accumulator-form rewrite in the source program (the standard ML/Haskell move) or a CPS transform (much more intrusive). 14e ships only the annotation + verification; accumulator forms become an authoring pattern in the stdlib, not a compiler feature. This sharpens what 14e can promise: tail-call **wins** will be visible in `print_list`-style terminal-recursion patterns; `map`/`sort` style stays stack-bounded by depth, which makes 14f (GC) the more important iter for handling long lists than 14e by itself. ## Iter 14e — explicit, verified tail calls Decision 8 ships. `Term::App` and `Term::Do` carry a `tail: bool` flag (serde-default false, skip-when-false in serialisation). A new `verify_tail_positions` typecheck pass walks each fn body and Lam body with an `is_tail_context: bool` threaded down per the standard Scheme rules, rejecting any `tail: true` call that sits outside tail position. Codegen emits `musttail call` for marked App calls. **Hash deltas (intentional, only the migrated calls):** | def | hash before | hash after | |---|---|---| | `list_map_poly.print_list` | unchanged 14c value | new | | `sort.print_list` | unchanged 14d value | new | All other defs across all 18 fixtures kept bit-identical hashes. This is the canary for the `skip_serializing_if = "is_false"` serde rule: an unmarked `App`/`Do` serialises identically to its pre-14e form, so untouched defs cannot drift. **Tests: 79/79 (was 76, +3).** New tests: - `tail_call_in_non_tail_position_is_rejected` (check unit) — asserts the diagnostic fires on a deliberately-misplaced `tail: true` call (e.g. as a `Cons` arg). - `tail_call_in_tail_position_is_accepted` (check unit) — asserts the verifier accepts the canonical `print_list` shape. - `iter14e_print_list_recursion_emits_musttail` (e2e IR-grep) — builds `list_map_poly`, dumps IR, asserts the recursive call site uses `musttail call`. This is the only direct evidence that the AST flag actually reaches LLVM. **IR-snapshot evidence** at the recursive site of `print_list` after migration: ``` %v7 = musttail call i8 @ail_list_map_poly_print_list(ptr %v6) ret i8 %v7 ``` `musttail` followed immediately by `ret` of the same SSA value — LLVM's terminator rule satisfied. Smoke: `list_map_poly` → `2/3/4`, `insertion_sort_orders_list` → identical sorted list. Behavior unchanged; only the calling shape did. **Two implementer deviations (called out, both reasonable):** 1. **`tail-do` falls back to `tail call`, not `musttail`.** LLVM `musttail` requires identical caller/callee return types. AILang IO ops dispatch through runtime helpers (`printf`/`puts`) returning `i32`, while AILang's `Unit` lowers to `i8`. Cross-type `musttail` would be rejected by the verifier. So `Term::Do` with `tail: true` lowers to `tail call` (the LLVM optimisation hint, not the guarantee), then `ret i8 0`. No fixture currently uses `tail-do`, so the path is implemented but not exercised end-to-end. Proper fix: change runtime helper signatures to return `i8`. Punted; not blocking. 2. **`block_terminated` plumbing in codegen.** A `tail-app` / `tail-do` emits `musttail call ... ret ...` directly and sets `self.block_terminated = true`. Surrounding code (match-arm phi construction, fn-body trailing-ret, lambda-thunk trailing-ret) checks the flag and skips the fall-through emit. When every match arm is a tail call, the join block is omitted entirely. This was unavoidable to keep the IR well-formed — adding a second `ret` after a `musttail call`+`ret` would be a verifier error. The flag is a small piece of state but it's the right shape for "the current basic block has been definitively terminated by a sub-emission". **Form (A) at constraint ceiling.** Two new productions (`tail-app-term`, `tail-do-term`) bring the count to ~30, which is exactly the constraint-1 budget. Future productions need to either retire something or accept an explicit budget rebalancing in DESIGN.md. **GC notes from the implementer (informs 14f).** - **Allocations cluster in `lower_ctor`** (~line 850 of codegen). Every `term-ctor` does `malloc(8 + 8 * n)`. In `print_list` we allocate nothing per recursion (just match + read fields + recurse); allocations come from `map`, from `main`'s list-building Cons chain, and from any other user code that builds ADTs. - **Lambda envs and closure pairs allocate too** (`lower_lambda`). Closure pair: `malloc(16)`. Env block: `malloc(env_size)`. Direct-application closures (the common case for HOF args) could be arena'd cleanly because the closure dies after the call returns. Stored or returned closures escape. - **Tail recursion does NOT reduce allocation pressure**, only stack depth. For `print_list`-style recursions there's no allocation to begin with, so the win is purely stack-bounded. For `map`-style ctor-blocked recursions, each step allocates one new `Cons` box — that's where allocation-side work pays off. - **The "obviously safe" arena boundary** is a fn whose return type contains no boxed ADT (i.e. returns `Int`/`Bool`/`Unit`/ `Str` only). All ADT boxes allocated inside such a fn cannot escape; an arena freed at fn return is sound by construction. Most current fixtures violate this — `map`, `sort` return ADTs — so a per-fn-arena scheme alone won't carry. Need a heap with GC for escaping allocations. This narrows 14f's design space: probably **Boehm conservative GC across the board** as a first cut (`GC_malloc` substituted for `malloc`, `-lgc` linked, no language change). Add a per-fn-arena optimisation later for non-escaping cases if the profile justifies it. Boehm is a one-iter shot; arenas would be a multi-iter design pass with escape analysis. **Plan 14f.** Boehm-GC integration. Concretely: - `GC_malloc` instead of `malloc` in lowered IR. - `-lgc` added to the clang link command in `ailang-codegen`'s build path (probably in the CLI, since the codegen crate emits IR text and clang is invoked downstream). - Conservative scan handles AILang's stack and globals out of the box. - Verify on a stress test: build a list of 100k Cons cells in `map`, run, observe RSS doesn't blow up. Boehm collects unreachable boxes during allocation pressure. - No AST or schema change. No language-level change. After 14f, the language is "done enough" for stdlib (15a). Anything else (records as a primitive, nested patterns, local recursive let, type classes) can layer on later without forcing stdlib rewrites. ## Iter 14f — Boehm conservative GC Decision 9 ships. Through Iter 14e every ADT box, lambda env, and closure pair was leaked. This iter substitutes `GC_malloc` for `malloc` in all four IR allocation sites and links `-lgc`. No language change, no AST change, no schema change. **Diff: 5 files, ~30 LOC net.** - `crates/ailang-codegen/src/lib.rs`: 4× `@malloc` → `@GC_malloc` (declare line + 3 call sites: `lower_ctor`, `lower_lambda`'s env, `lower_lambda`'s closure pair). - `crates/ail/src/main.rs`: `.arg("-lgc")` added to the clang invocation in `build_to`. - `crates/ail/tests/snapshots/{hello,sum,list,max3,ws_main}.ll`: mechanical s/@malloc/@GC_malloc/, 9 occurrences across 5 files. The IR is bit-identical to pre-14f modulo this substitution — exactly Decision 9's promise. - `crates/ail/tests/e2e.rs`: new test `gc_handles_recursive_list_construction` (+19 LOC). - `examples/gc_stress.{ailx,ail.json}`: new fixture. **Hash invariance verified.** Every existing fixture's def hashes are unchanged. The codegen and link line are downstream of canonical bytes; the AST schema didn't move; nothing on disk in `examples/*.ail.json` was touched. The new `gc_stress` module adds 4 new hashes, all unrelated. **Tests: 80/80 (was 79).** Existing 79 produce byte-identical stdout — only the allocator changed, semantics unchanged. New: `gc_handles_recursive_list_construction` builds a List of length 50 via recursive `Cons`, sums it (`1275`). Manual smoke: - `gc_stress.ail.json` → `1275`. - `list_map_poly` → `2 3 4` (unchanged). - `sort` → sorted list (unchanged). `cargo doc --no-deps`: 0 warnings (DESIGN.md item 6 invariant preserved through nine iters of feature work). **Pattern shape used in `gc_stress`** (caught a small typechecker constraint). The first proposed shape `(case (lit-int 0) Nil)` doesn't parse — `pat-lit` takes the bare literal token, not a keyword-prefixed form, and `case` requires a pattern. Worked shape: comparison-and-bool-match, mirroring `sort.ail.json`'s `<=` arm: ``` (match (app == n 0) (case (pat-lit true) (term-ctor List Nil)) (case (pat-wild) (term-ctor List Cons n (app build (app - n 1))))) ``` This is the canonical "if-then-else" pattern post-14d. Worth flagging for the stdlib brief: predicates that need to branch go through the `==` / `<` / `<=` builtin returning Bool, then match on that Bool with a wildcard fallback. Three lines for what `if` used to do in one — but uniform with the rest of the language, no special case. **GC integration notes.** - `GC_INIT()` is **not needed** on this build host (Arch with `libgc 1.5.6`). libgc auto-inits via `__attribute__((constructor))`. - No conservative-scan over-retention symptom observed: every existing test's stdout byte-identical; behaviour preserved. - `-lgc` alone is sufficient for the link; pthread/dl come in transitively from libgc.so's NEEDED entries. **Language is feature-complete enough for stdlib.** Iters 14d (redundancy removal), 14e (explicit tail calls), 14f (GC) are the three blockers identified at the 14b boundary. They are all done. Anything else (records as primitive, nested patterns, local rec let, type classes) layers on later without forcing stdlib rewrite. **Plan 15a.** First stdlib module: `examples/std/std_list.ailx`. Combinators: `length`, `append`, `reverse`, `map`, `filter`, `fold_left`, `fold_right`, `head`, `tail`, `is_empty`. Each combinator a fresh test vector for the parameterised-ADT + GC + tail-call combination. Authored in form (A) from day one; `.ail.json` produced via `ail parse`. Each combinator gets a dedicated e2e test. If 15a surfaces compiler bugs (likely — every prior dogfood iter has, see 14a's monomorphisation bug), debugger handles them inline. If a compiler limitation surfaces that genuinely blocks the stdlib (e.g. nested patterns turn out to be needed), that becomes its own iter before 15a continues. The architectural pin from Decision 6 governs: stdlib lives under `examples/std/` as `.ailx` source; tests load the generated `.ail.json`. `ailang-check` and `ailang-codegen` remain projection-agnostic. ## Iter 14g — `Term::If` restored (revert of 14d) Reconsidered 14d's removal of `Term::If`. The decision was wrong; restored. **Why 14d was wrong.** "No redundancies" from CLAUDE.md is a real rule but it requires judgment to apply. `Term::If` reduces to `Term::Match` on Bool, but reducibility is not redundancy in a strong sense — `1 + 1` reduces to `2`, you don't remove `+` from the language because of it. `Term::If` is a primitive control- flow shape that every programming language has for good reason: bool branching is the second most common control flow shape after sequencing. **Quantitative.** `(if c a b)` is 4 tokens. The post-14d replacement `(match c (case (pat-lit true) a) (case (pat-wild) b))` is 12. That's a 3× token-economy hit on every Bool branch — in exactly the language whose authoring constraint was supposed to be token-efficient. The match-on-Bool form is also asymmetric (false case via `pat-wild` because the typechecker rejects `pat-lit false` as exhaustive) and structurally lopsided. **Meta-pattern that produced the wrong call.** I had been treating user observations as directives. The user said "if is a subset of match" — which is a factual observation; I jumped to remove it, citing CLAUDE.md as cover. There was no independent conviction behind the change, only doctrinal hooking-up of a user remark. The leak showed up in 14f's JOURNAL prose ("three lines for what `if` used to do in one"), which the user correctly read as me regretting the decision. Two feedback memories saved to head this off in future iters (`/home/brummel/.claude/projects/-home-brummel-dev-ailang/memory/`): - `feedback_user_suggestions_not_directives.md` — observations are input, not output. Form an opinion before acting. - `feedback_no_nostalgia_for_removed_features.md` — describe canonical form on its own merits, not as compensation for what was deleted. **Implementation (revert).** Mechanically reverse-applied 14d's diff at every site (`ast.rs`, `check/lib.rs` (incl. the new 14e `verify_tail_positions` arm), `codegen/lib.rs` (4 sites), `surface/{parse,print}.rs`, `core/pretty.rs`, `ail/main.rs`, `e2e.rs` test mutation). Removed the `lower_bool_match` helper that 14d had introduced — it existed only because the 14d migration shape needed codegen for non-`ptr` match scrutinees; with `Term::If` back, match-on-Bool returns to its pre-14d unsupported state and the helper is dead weight. Three fixtures (`sum`, `sort`, `max3`) restored to their pre-14d shape. **One additional fixture migration.** `gc_stress` was authored in 14f using the 14d match-on-Bool migration shape (because 14f sat between 14d and this revert). After removing `lower_bool_match` it would have failed to compile. Migrated `gc_stress.{ail.json,ailx}` to use `(if ...)` directly. Output unchanged: `1275`. **14e and 14f are intact.** Verified by spot-emit of `list_map_poly`'s IR: `musttail call i8 @ail_list_map_poly_print_list` and `call ptr @GC_malloc(i64 8)` both present. The revert is strictly local to `Term::If`-related code paths. **Hash check.** All four pre-14d hashes returned: | def | restored hash | |---|---| | `sum.sum` | `db33f57cb329935e` | | `sort.insert` | `697fcb9f30f8633a` | | `max3.max` | `65c45d6a45dd0a72` | | `max3.max3` | `624b14429bf302f5` | Untouched defs across all 18 fixtures (incl. the 14e print_list hash deltas) keep their post-14f hashes. The revert's hash movement is exactly the four 14d-migrated defs reverting plus the one accidental 14f-victim (`gc_stress` defs, never previously shipped under any other hash). **DESIGN.md.** Decision 7 is preserved with a `Status: REVERTED` header. Audit trail matters; future reads should see the decision and its reversal both. Form-(A) productions in Decision 6's appendix have `if-term` restored. **Tests: 80/80 green.** Identical stdout for every existing fixture. `cargo doc --no-deps` 0 warnings. **LOC delta.** +265/-295 net −30. Net cleanup: the `lower_bool_match` helper was bigger than the restored `Term::If` codegen. **Plan.** Back to 15a — first stdlib module `std_maybe`. The brief I had drafted included an "authoring note: post-14d if-then-else" section that's now obsolete. Re-issue without that, using `if` naturally where appropriate. ## Iter 14h — cross-module parameterised-ADT import (15a unblocked) The 15a tester surfaced exactly the kind of bug a first-real- stdlib-iter is supposed to surface: cross-module references to types and ctors were not implemented. The Iter 5b cross-module mechanism only carried fns + consts via `module_globals`; types and ctors stayed module-local with an explicit comment in `crates/ailang-check/src/lib.rs:703`: *"Register type defs (local per module; cross-module ADT sharing is explicitly not part of 5b)"*. This iter completes that work using the same convention as fns: **qualified-only access via `module.Name`**. (Note on git tidiness: the `examples/std_maybe.ailx` file was authored by the cancelled 15a tester dispatch and got swept into the 14g commit by a `git add -A`. Should have spotted it pre- commit. Not a correctness issue — the file was complete and correctly authored — but a process-hygiene one. Will check the diff carefully before staging next time.) **The bug, surfaced by the 15a demo.** ``` ail check examples/std_maybe_demo.ail.json --json [{"severity":"error","code":"unknown-type", "message":"unknown type: `Maybe`","def":"main","ctx":{}}] ``` After qualifying the fn calls (`std_maybe.from_maybe`), fn refs worked but the `(con Maybe (con Int))` and `(term-ctor Maybe Just 7)` kept failing because `env.types` and `env.ctor_index` are populated only from the current module. **The fix.** Same shape as Iter 5b's fn solution, applied to types and ctors: - `Env` gains `module_types: BTreeMap>` populated by a sibling `build_module_types` to `build_module_globals`. Lives in `check_in_workspace`'s pre-check pass. - Type resolution in `(con NAME args)`: if `NAME` contains exactly one `.`, split into `module.type` parts and resolve via `env.module_types[module]`. Else current behaviour. - Term-ctor resolution in `Term::Ctor { type, ctor, args }`: same split rule on the `type` field. The `ctor` field stays unqualified — once the type is resolved, ctor lookup is unambiguous within the type def. - Pattern-ctor resolution: when the bare ctor name doesn't resolve in the local `ctor_index`, fall back to scanning imported modules' types. Conflict rule: local always wins; if multiple imported modules declare the same ctor name, error with the new diagnostic code `ambiguous-ctor`. - Codegen mirrors: a workspace-level `module_ctor_index` replaces the per-Emitter table. `lookup_ctor_by_type` / `lookup_ctor_in_pattern` thread qualified type names through the box-tag and field-type resolution paths. **Diff size: 4 files, ~550 LOC net.** `ailang-check`: +311 (env + four resolution sites + 4 unit tests). `ailang-codegen`: +230 (workspace ctor index + qualified type-name handling). One new diagnostic code. Demo updated to use `std_maybe.Maybe` at type-name slots. **Tests: 85/85 (was 80, +5).** Four new unit tests in `ailang-check` covering: qualified type ref, qualified term-ctor, pat-ctor cross-module fallback, pat-ctor ambiguous-ctor diagnostic. One new e2e test `cross_module_maybe_demo` asserts stdout `["7", "99", "true", "true", "42"]`. **Hash invariance: confirmed.** All five `std_maybe` def hashes unchanged (`Maybe 0fb8eaacba5e1135`, `from_maybe caf8eeaca800c80d`, `is_some c09002048ff1ff6e`, `is_none 144e131340b58bd3`, `map_maybe 68d83d84799322fa`). All other 80-test-suite fixtures retain bit-identical hashes — the cross-module support is purely additive at the language level. **14a-era regressions held.** Spot-checked `parameterised_box_round_trip`, `parameterised_maybe_match`, `list_map_poly_inc_then_prints`, `polymorphic_id_at_int_and_bool` — all green. The 14h `derive_substitution` change (default unpinned forall vars to `Unit` for the monomorphiser) sits on a different layer than 14a's `synth_arg_type` `$u`-wildcard fix (for nested ctor type synth). Both coexist: - 14a's `$u`-wildcard short-circuits unification when a sibling arg pins the same type var concretely. - 14h's Unit default applies when no arg pins a forall var at all (e.g. `is_none(Nothing)` — `a` in `Maybe` is genuinely unobservable from `Nothing`). **Implementer note (flagged for future):** the Unit default produces a single shared monomorphisation for all such unconstrained-`a` call sites. Wasteful but correct. If the stdlib grows toward overload-resolution-style cases where unconstrained instantiations need to be distinguished, the descriptor strategy needs rethinking. Punted; not a 15a blocker. **std_maybe stdlib effectively ships.** Module + four combinators + e2e-tested consumer demo. The cross-module parameterised-ADT pipeline is the missing piece that 13a/b/c (parameterised ADTs) and 5b (cross-module fns) could not by themselves cover. This iter closes that loop. **Plan 15b.** Now that `Maybe` is reusable across modules, write `std_list.ailx` importing `std_maybe`. Combinators: `length`, `head` (returns `Maybe`), `tail` (returns `Maybe>`), `is_empty`, `append`, `reverse`, `map`, `filter`, `fold_left`, `fold_right`. `head`/`tail` exercise the cross-module Maybe-returning case. `fold_left` is the tail-recursive variant and gets `(tail-app ...)` markers; the constructor-blocked combinators (`map`, `filter`, `append`, `fold_right`) stay unmarked. If a new compiler bug surfaces during stdlib construction (each prior dogfood iter has surfaced one), debugger handles it inline. ## Iter 15b — `std_list` ships, three more compiler gaps closed Second stdlib module. Tester wrote `std_list.ailx` (164 LOC, 10 combinators) plus `std_list_demo.ailx` (consumer importing both `std_maybe` and `std_list`). `std_list.ail.json` typechecked cleanly in isolation. The demo did not — the prediction "every dogfood iter surfaces at least one compiler bug" held three times over. **Tester's diagnosis** was sharp enough that the orchestrator could go straight to implementer without a debugger round: > Iter 14h's `qualify_local_types` is applied at `Term::Var` > cross-module lookup but **not** to ctor-field types when > `Term::Ctor` is synthesized. For `Cons a (List a)`, `List` > stays unqualified in `cdef.fields`. `std_maybe` slipped > through because its ctors have no recursive Con field. First > recursive ADT shared cross-module triggers it. **The original-spec fix** was ~10 LOC across two sites in `ailang-check/src/lib.rs` — apply `qualify_local_types` over `cdef.fields` before substituting forall vars, in both `Term::Ctor` synth and `Pattern::Ctor` resolution (the latter symmetric, no current consumer hit it but it's the same underlying gap). **Two more bugs surfaced during implementation** of that fix: 1. **Codegen-side qualify-fields, four sites.** `ailang-codegen` has its own field-type tracking that mirrored the check-side bug. Symmetric fix needed in `Term::Ctor` synth + `lower_ctor`, in `lower_match` for cross-module ADT scrutinees, and a tweak to `unify_for_subst` to recurse instead of strict-equality when re-binding a forall var that already has a previous concrete binding (so a sibling-derived `List` accepts a nullary ctor's `List<$u>` wildcard). 2. **Const codegen for non-literal values.** The demo defines a top-level `xs : List` const whose body is a Cons chain. `check_const` already accepts pure non-literal const bodies, but `emit_const` rejected them. Fix: register `module_consts` in pass 1, resolve const refs in `Term::Var` (load from global for literal consts, inline body for non-literal). Both bare and qualified const refs (`xs` and `module.xs`) supported. All three fixes together: ~349 / 25 LOC across `ailang-check`, `ailang-codegen`, and the new e2e test. Each fix carries an `Iter 15b` code comment at its site. **Tests: 87/87 (was 85, +2).** New e2e `std_list_demo` asserts the 11-line stdout sequence: ``` 5 (length [1..5]) false (is_empty [1..5]) true (is_empty []) 1 (head [1..5] via from_maybe) 4 (length of tail) 10 (length of [1..5] ++ [1..5]) 5 (head of reverse [1..5]) 2 (head of map double [1..5] = [2,4,6,8,10]) 2 (length of filter is_even [1..5] = [2,4]) 15 (fold_left + 0 [1..5]) 15 (fold_right + 0 [1..5]) ``` New unit test `cross_module_recursive_adt_term_and_pat_ctor` in `ailang-check` covers both Term::Ctor and Pattern::Ctor paths against a recursive cross-module ADT. Catches the original bug + its symmetric pat-ctor latent twin. **Hash invariance.** All 22 pre-15b fixture hashes plus `std_maybe`'s five def hashes bit-identical. The 15b changes were purely additive on the language side. **14a-era and 14h-era regressions held.** Spot-checked `parameterised_box_round_trip` (14a), `cross_module_maybe_demo` (14h), `list_map_poly_inc_then_prints` (14e). All green. **Authoring observation from the tester.** Form (A) holds up to 10 combinators in one module without breaking. The highest- overhead pieces are typed `lam` (each closure carries `(typed name type)` triples + return-type + effects-clause) and the outer `(forall (vars a b) (fn-type ...))` wrapper. Repeated paren-counting at the bottom of nested `seq` chains was the only real friction during demo authoring. Suggests a future iter might add a `seq*` n-ary form, but the n-ary case is sugar over the current `seq` shape and not load-bearing. **Cumulative state, post-15b.** - Stdlib modules: 2 (`std_maybe`, `std_list`). - Combinators: 14 (`Maybe` + 4; `List` + 10). - Cross-module imports: type-side, ctor-side, fn-side all working. - Recursive cross-module ADTs working. - Tail-call markers used in production: `fold_left` in `std_list`, `print_list` in two existing fixtures. - Compiler bugs surfaced and fixed in dogfood: - 14a: monomorphisation `Type::unit()` placeholder collision. - 14h: cross-module type/ctor not implemented. - 15b: qualify-fields gap (3 layers: check, codegen, plus const). **Plan 15c.** Stress test on real-shape data. Build a list of ~1000 elements, run `fold_left` and `fold_right` over it, verify both produce the expected sum. The point: empirically confirm that `fold_left`'s tail-call marker actually prevents stack overflow under load, while `fold_right` (constructor-blocked, unmarked) can still run at this scale because it's only ~1000 deep, not 1M. If `fold_right` segfaults on this scale, that's a useful boundary; if it works, we know the stack budget on this host is at least a few thousand frames. After 15c, optional: `std_pair` (2-tuple ADT), `std_either` (disjoint union for error handling). Or move on to a non-stdlib feature like nested patterns — at this scale of language, the case for adding a feature can be made directly from a stdlib annoyance. ## Iter 15c — empirical TCO + stack-budget validation at N=1000 Small empirical iter to confirm the TCO story works dogfood- practical, not just on the contrived `print_list` recursion that was the 14e regression target. Fixture `examples/std_list_stress.ailx` builds a 1000-element `List` via the recursive `build` fn (also used in 14f's `gc_stress`), then folds it with both `std_list.fold_left` and `std_list.fold_right`, prints both sums. Both should be `500500` (1000 × 1001 / 2). E2E test asserts the two-line output. **IR evidence at the monomorphised fold-recursion sites:** ``` %v12 = musttail call i64 @ail_std_list_fold_left__I_I(...) ; tail %v7 = call i64 @ail_std_list_fold_right__I_I(...) ; non-tail ``` The `tail: true` marker on `std_list.fold_left`'s recursive call survives monomorphisation through the `__I_I` specialisation — exactly what 14e's machinery was supposed to do. `fold_right` correctly emits a plain `call` (its recursive call is the second arg to `f`, not in tail position). **Empirical findings.** - `fold_left` at N=1000 runs in constant stack depth (musttail). - `fold_right` at N=1000 runs with ~1000 stack frames. No segfault. LLVM's frames at `-O0` are small enough that the default 8MB Linux stack absorbs this comfortably. - `build` (also unmarked, recursive) likewise fits. - End-to-end binary execution time ~40ms wall, of which the bulk is build + clang link; the actual program runs in <1ms. - Two `build 1000` chains plus both folds = ~3000 frames total for the unmarked recursions; still fine. **Tests: 88/88 (was 87, +1 e2e).** Cumulative 30 e2e tests. **Cumulative state, post-15c.** - Stdlib modules: 2 (`std_maybe`, `std_list`). - Combinators: 14. - Cross-module imports: type-side, ctor-side, fn-side, recursive ADT support — all working. - TCO: marker propagates through monomorphisation; `musttail` reaches LLVM at the right call sites. - GC: Boehm runs at 1000-element scale without intervention (no `GC_INIT()` needed, no symptom of conservative-scan over-retention at this scale). - Compiler bugs surfaced and fixed in dogfood since 14a: 4 (14a monomorphisation, 14h cross-module ADT, 15b qualify- fields-codegen + non-literal-const-codegen). **Natural pause point.** The language is now genuinely useful for small-to-medium programs. The 14b-through-15c arc was substantial: a textual surface, two language-completion iters (tail calls + GC), one revert (14d→14g), a cross-module ADT gap closure, two stdlib modules, and an empirical stress test to validate TCO. Work since the user's "Lege los": 9 commits. **Queue for future iters.** - **15d (optional)**: `std_either : Either e a = Left e | Right a` for error propagation. Small module (~5 combinators), would exercise the cross-module-with-2-type-vars import path (currently only Maybe exercised at one type var). - **15e (optional)**: `std_pair : Pair a b = MkPair a b` plus `fst`, `snd`, `swap`, `map_first`, `map_second`. 2-type-var parameterised ADT, no recursion. Smaller dogfood than List. - **16a (language)**: nested patterns. Current pattern shape is flat (a `pat-ctor`'s fields are `pat-var | pat-wild | pat-lit`, not nested `pat-ctor`). This becomes painful when stdlib code wants to match `Cons h (Cons h2 _)` directly. Manageable today via nested `match` but would simplify several stdlib idioms. - **16b (language)**: local recursive `let`. Currently must hoist to top-level. Painful for stdlib helpers that are clearly internal to one combinator (e.g. an accumulator-loop inside `reverse`'s body). - **17a (codegen optimisation)**: per-fn arena for non-escaping ADT allocations (Decision 9's flagged future iter). Real win for `map`/`filter`-style combinators where the intermediate list is dropped immediately. Needs escape analysis; multi-iter design pass. None of these block further stdlib work. They are quality-of- life improvements; the language is feature-complete enough that the stdlib can grow without them. --- ## Iter 15d — `std_either`: 2-type-var ADT + 3-type-var eliminator **Goal.** Third stdlib module. Either is the canonical 2-type-var disjoint sum. The eliminator combinator `either : (e → c) → (a → c) → Either → c` introduces a third type var on top of the data, making it the most polymorphism-dense fn shipped to date. **What shipped.** - `examples/std_either.ailx` (74 LOC). Defines `Either e a = Left e | Right a` plus 5 combinators: - `from_right : a → Either → a` — eliminate Right or use default. - `is_left`, `is_right : Either → Bool`. - `map_right : (a → b) → Either → Either` — Functor-style on the Right side. - `either : (e → c) → (a → c) → Either → c` — catamorphism / fold-on-sum. - `examples/std_either_demo.ailx` exercises every combinator, including the eliminator with two distinct concrete instantiations (over `Left 5` and over `Right 100`). - Canonical JSON for both, parsed via `ail parse`, type-checked, built, and executed end-to-end. **Output (deterministic).** ``` 42 ; from_right 0 (Right 42) 99 ; from_right 99 (Left 7) true ; is_left (Left 7) true ; is_right (Right 42) 42 ; from_right 0 (map_right inc (Right 41)) 6 ; either inc inc (Left 5) 101 ; either inc inc (Right 100) ``` **Monomorphisation evidence.** Six distinct instantiations across the five combinators, all generated correctly with the `$u` wildcard for unused type-var positions: ``` @ail_std_either_from_right__I_I ; e=Int, a=Int @ail_std_either_from_right__U_I ; e=$u, a=Int @ail_std_either_is_left__I_U ; e=Int, a=$u @ail_std_either_is_right__U_I ; e=$u, a=Int @ail_std_either_map_right__U_I_I ; e=$u, a=Int, b=Int @ail_std_either_either__I_I_I ; e=Int, a=Int, c=Int ``` The two `from_right` variants are notable: same source fn, two different concrete `e` per call site (Int when scrutinee is Left 7; $u when scrutinee is Right _ since Left's payload is unused). The 14a monomorphisation machinery picks the right one per site without extra ceremony. The 3-type-var instantiation `either__I_I_I` is the deepest type substitution shipped through monomorphisation so far. Both call sites in main hit the same instantiation (`e=a=c=Int`) so a single emit suffices. **No new compiler bugs surfaced.** First stdlib iter without a fresh codegen/check fix. Indicator that 14a / 14h / 15b coverage was wide enough to handle 2-type-var data + 3-type-var fns out of the box. **Discovered (not fixed in this iter):** `ail render` and `ail parse` are not symmetric, despite the help text in the CLI claiming they are. `render` calls the older `ailang_core::pretty::module` human-pretty printer (form B-ish), while `parse` consumes form (A). The form-(A) printer in `ailang_surface::print` is correctly the inverse of `parse` — confirmed by the round-trip test in `crates/ailang-surface/tests/round_trip.rs` which now covers 25 fixtures including `std_either.ail.json` — but it is not exposed via the CLI. Logged as 15e. **Tests: 89/89 (was 88, +1 e2e for std_either_demo).** Cumulative 31 e2e tests. **Cumulative state, post-15d.** - Stdlib modules: 3 (`std_maybe`, `std_list`, `std_either`). - Combinators: 19. - Type-system surface area exercised: 1-type-var data (Maybe, List with recursion) and 2-type-var data (Either); 1- and 2- and 3-type-var polymorphic fns; cross-module recursive ADTs; monomorphisation-with-wildcards across all. **Queue update.** 15d done; remaining queue: 15e (CLI render/parse symmetry — small, just discovered), then `std_pair` if more stdlib is wanted, then 16a/16b language gaps. --- ## Iter 15e — `render` is the inverse of `parse` (CLI hygiene) **Trigger.** Discovered during 15d: `ail render | ail parse` did not round-trip. The help text claimed they were inverses but `render` called `ailang_core::pretty::module` (an old human-readable S-expression-ish form), while `parse` consumes form (A). Two different "text projections" lived in the codebase, one of them exposed via the CLI under a name that promised inversion. The form-(A) printer in `ailang_surface::print` was already correct and gated by `crates/ailang-surface/tests/round_trip.rs` across every fixture (25 modules at the time of writing) — it was simply not exposed at the CLI surface. **Changes.** 1. `Cmd::Render` now calls `ailang_surface::print(&m)`. Help text updated to state explicitly: form (A), exact inverse of `parse`, round-trippable. 2. `Cmd::Describe` (both single-module and `--workspace` branches) likewise switched to `ailang_surface::print` so that "text view of one def" means the same thing everywhere in the CLI. 3. `ailang_core::pretty::module` deleted. With `module` gone its helpers (`def_block`, `term_block`, `term_inline`) became dead weight — also deleted. ~260 LOC of duplicate-purpose code gone; `crates/ailang-core/src/pretty.rs` shrank from 457 to 196 LOC. 4. The remaining surface of `ailang_core::pretty` — `manifest`, `type_to_string`, `pattern_to_string` — is the diagnostic stringification surface (used in error messages and the `ail manifest` summary, where one-line ML notation is more readable than form (A)'s nested S-expression). Module-level doc rewritten to make this single-purpose framing explicit; no more "pretty-printer" / "render" overloading. 5. New e2e test `render_parse_round_trip_canonical` (in `crates/ail/tests/e2e.rs`) gates the CLI shell pipeline: shell out `ail render ` → write to tmp .ailx → shell out `ail parse ` → assert canonical-byte equality with the original fixture. The unit-level round-trip in `ailang-surface/tests/round_trip.rs` covered the printer function directly; this new test additionally guards the CLI wiring. 6. The `ailang_core::pretty` unit test `pretty_print_does_not_panic` exercised the deleted `module` fn — removed. `manifest_contains_ type_and_hash` stays. **Doctrinal pin.** Form (A) is the only round-trippable text form. The diagnostic stringification in `ailang_core::pretty` is asymmetric and lossy by design — it exists for the *limited* purpose of sticking a type or pattern into an error message in human-friendlier shape than form (A). It does not roundtrip and must not be used as an authoring or persistence target. Any future add to it should add to that purpose, not back-fill toward "another text projection". **Tests: 89/89.** (e2e went from 31 to 32; ailang-core unit tests went from 11 to 10 — the deleted unit test exercised the removed fn. Net same.) **Cumulative state, post-15e.** No new language features. Two canonical text projections collapsed to one (form A). Internal cleanup; no behavioural change for any program in the repo. --- ## Iter 16a — nested constructor patterns in `match` **Goal.** Lift the gate that rejected `(pat-ctor Cons a (pat-ctor Cons b _))` and similar nested-Ctor sub-patterns. Lit-in-Ctor stays rejected; that's a separate iter. **Approach: AST-level desugar before check + codegen.** Pure rewrite that flattens nested Ctor patterns into chains of single-level matches with let-bound fresh vars and duplicate fall-through. Hash-relevant canonical bytes untouched because the pass runs *after* `load_module`, in memory only. The checker / codegen always see flat patterns. **Algorithm sketch.** For a `Match` whose arms contain nested-Ctor sub-patterns: 1. Bottom-up: recursively desugar scrutinee and arm bodies first. 2. Let-bind the scrutinee to `$mp_N` (single eval). 3. Build a chain `arm_1 (else arm_2 (else ... default))`. Default is `Lit Unit`, unreachable for valid programs. 4. Each arm's nested Ctor sub-patterns are lifted to fresh vars, then deepest-first wrapped via `wrap_sub`, which on a Ctor sub recursively re-enters `desugar_match` — that recursion handles arbitrary depth. `fall_k` is cloned per inner level → O(arms × depth) terms in worst case. Acceptable for typical patterns. **Fresh-name safety.** `$` is a valid identifier character in form (A) (the lexer's `Ident` token is "anything not paren/int/string"), so `$mp_0` is theoretically user-writable. The Desugarer pre-walks every `Term::Var` and `Pattern::Var` name in the module into a `BTreeSet` and bumps the counter past collisions. **Files.** - New: `crates/ailang-core/src/desugar.rs` (571 LOC including doc-comments and two unit tests). Public surface is `pub fn desugar_module(m: &Module) -> Module` — pure, idempotent, no I/O. - `crates/ailang-core/src/lib.rs` — `pub mod desugar;` plus a module-doc bullet describing the new pipeline layer. - `crates/ailang-check/src/lib.rs` — three public entries (`check_module`, `check_workspace`, `check`) call `desugar_module` first. The gate at the old line 1538 is now narrowed: nested **Ctor** sub-patterns are `unreachable!()` (desugared away); nested **Lit** still emits the existing `nested-ctor-pattern-not-allowed` diagnostic. Crucially: `check`'s returned `CheckedModule.symbols` keeps hashes of the *original* defs so `ail diff` and `ail manifest` see the on-disk identity, not a post-desugar one. - `crates/ailang-codegen/src/lib.rs` — `lower_workspace` desugars every module up front; `emit_ir` (single-file shortcut) goes through `lower_workspace` so the desugar runs there too. - New: `examples/nested_pat.ailx` + `nested_pat.ail.json`. `first_two_sum` matches `(pat-ctor Cons a (pat-ctor Cons b _))`; prints `30` for the input `[10, 20, 30]`. - `crates/ail/tests/e2e.rs` — new `nested_ctor_pattern_first_two_sum` test. **Behavioural property of the desugar.** Already-flat matches go through `is_flat()` and emit identical AST shapes. Empirically: every existing fixture (std_maybe, std_list, std_either, list_map, sort, etc.) produces the same observable output as before because their patterns were already flat — the desugar is a no-op clone on them. **Tests: 92/92.** - e2e: 33 (was 32, +1 for nested_pat). - ailang-core unit: 12 (was 10, +2 for the desugar tests). - All other crates unchanged. **No new compiler bug surfaced.** The transform was straightforward because the AST already supported nested sub-patterns at the type level — only the checker gate and codegen drop-on-floor were artificial walls. Removing them via desugaring (rather than expanding the codegen) keeps the pattern-matching backend simple and lets future iters (Lit-in-pattern, exhaustiveness, decision trees) plug in at the same desugar layer. **Cumulative state, post-16a.** - Stdlib: 3 modules, 19 combinators (unchanged from 15d). - Language: nested Ctor patterns now legal; nested Lit-in-pattern still rejected (intentional follow-up scope). - Pipeline: `load → desugar → check → codegen`. The desugar layer is the natural home for further surface-level smoothing (Lit-in-pattern, `if`-as-syntactic-sugar, etc.) without enlarging the core AST. - Compiler bugs surfaced and fixed in dogfood since 14a: still 4 (no fresh ones in 16a — the desugar landed clean). **Queue update.** 16a done. Remaining: 15f (`std_pair`, optional); 16b (local recursive `let`); 17a (per-fn arena); future "lit-in-Ctor" follow-up under 16c if and when needed. --- ## Iter 15f — `std_pair`: 2-type-var product, no recursion **Goal.** Round out the small-ADT stdlib triad (Maybe, Either, Pair). Pair is the canonical product with two type vars and a single constructor — no recursion in the data def or any combinator. Smallest dogfood for the parameterised-ADT path so far. **What shipped.** - `examples/std_pair.ailx` (~75 LOC, 5 combinators): - `fst`, `snd` — projections. - `swap : Pair -> Pair`. - `map_first : (a -> c) -> Pair -> Pair`. - `map_second : (b -> c) -> Pair -> Pair`. Each combinator is a single-arm match on `MkPair` with no fall-through, so the desugar pass added in 16a is a no-op (the patterns are already flat). - `examples/std_pair_demo.ailx` exercises every combinator. Output (deterministic): 7, 9, 9, 7, 8, 18. - `crates/ail/tests/e2e.rs::std_pair_demo` guards the path. **No new compiler bug surfaced.** Stdlib iter #4 in a row that landed clean. The only fixable issue was a paren-balance typo in the demo's `seq` chain, surfaced immediately by the parser's "unexpected end of input, expected `)`" diagnostic. **Tests: 93/93** (e2e went from 33 to 34). **Cumulative state, post-15f.** - Stdlib: 4 modules (`std_maybe`, `std_list`, `std_either`, `std_pair`); 24 combinators total. - Type-system surface area exercised end-to-end: 1- and 2- and 3-type-var data; 1- and 2- and 3-type-var fns; recursive ADTs; cross-module imports of all of the above; flat *and* nested patterns (16a); TCO via monomorphised `musttail`; Boehm GC. - Pipeline layers: `load → desugar → check → codegen → clang`. Each layer has a public, narrow contract; the desugar layer is the natural home for further surface-level smoothing without enlarging the core AST. **Queue update.** 15f done. Remaining: 16b (local recursive `let`), 16c (Lit-in-Ctor patterns), 17a (per-fn arena). None blocking further stdlib growth; each is a quality-of-life improvement. --- ## Iter 16a-aux — DESIGN.md drift audit (post-15f) **Goal.** After six feature iters (14g, 14h, 15a–15f, 16a) without a docs sweep, the design doc had accumulated visible drift. Patch in place rather than queueing the next codegen-heavy iter against a stale spec — the user is unreachable for the broader memory-management discussion that gates 17a, so this is the lowest-risk productive move. **Drift sites found and fixed (DESIGN.md +72 LOC).** 1. **Decision 6 status (L116).** Was: `(Iter 14b — WIP) ... Status: design pass in progress`. Now: marks form (A) as shipped in Iter 14c, gated by the round-trip test in `ailang-surface/tests/round_trip.rs`, and notes that 15e made it the *sole* text projection (legacy pretty-printer module helpers deleted). The body of the section (constraints, candidate notations) remains as the audit trail of the original design pass. 2. **Pipeline section (L687–696).** Inserted the **desugar pass** between resolve+hash and typecheck, with the load-bearing invariant explicit: `CheckedModule.symbols` hashes from the *original* module, not the desugared one, so `ail diff` / `ail manifest` keep reporting the on-disk identity. Also noted libgc linkage on the clang line. 3. **CLI section (L700–708).** Added the four subcommands that shipped in earlier iters but never made the doc: `deps`, `diff` (single-module + `--workspace`), `workspace`, and `builtins`. Reformatted to a two-column layout. 4. **"What is not (yet) supported" (L724–746).** Re-anchored from "end of Iter 13" to "as of Iter 16a". Removed three gates that had been lifted: cross-module ADTs (14h), no-GC (14f, Decision 9), flat-pattern-only (16a). Replaced with tighter follow-up gates: literal sub-patterns inside Ctor patterns; local recursive `let`. Added a *one-paragraph* "recently lifted" preamble so a future reader sees both the delta and the iter that produced it without consulting JOURNAL. 5. **"What IS supported" (L782+).** Promoted four capabilities into the smoke-test list: nested Ctor patterns via desugar (16a); cross-module ADTs (14h); the form-(A) text surface as shipped (14b/14c/15e); Boehm GC (Decision 9 / 14f). Replaced "ADTs + flat pattern matching" line with one that names both the original 3 and the 16a extension. 6. **Pipeline regression smoke tests (L819–).** Added the four stdlib-demo fixtures (`std_list_demo`, `std_maybe_demo`, `std_either_demo`, `std_pair_demo`) plus `nested_pat`. The pre-existing entries (sum/list/hof/closure/list_map/sort/ poly_id/poly_apply/box/maybe_int) were preserved as-is. **No code changes; tests still 93/93.** Verified with `cargo build --workspace --quiet` — the doc-only edits do not affect any compilation unit. **What was *not* changed.** The Goal section, Decisions 1–5, Decisions 7–9 (already accurate after their respective revert / ship statuses), Mangling scheme, Convention for cross-module references, Data model (Module/Def/Term/Type grammar), Verification section. Spot-checked each — all match current implementation. **Cumulative state, post-16a-aux.** - Stdlib: unchanged (4 modules, 24 combinators). - DESIGN.md: 804 → 876 LOC. JOURNAL.md grows by this entry. - Drift baseline reset: any future iter that lifts a gate or ships a new pipeline layer should patch the relevant section in the same iter, not accumulate. **Queue update.** 16a-aux done. Unchanged from post-15f: 16b (local recursive `let`), 16c (Lit-in-Ctor patterns), 17a (per-fn arena). 17a remains the explicit checkpoint before any broader memory-management work — that decision is gated on a joint conversation with the user. --- ## Iter 15g — std_either_list: first 3-way cross-module stdlib fn **Goal.** Through 15f the stdlib had 4 modules but no fn imported more than one foreign module (e.g. `std_list.head` returning `std_maybe.Maybe`). 15g introduces the first stdlib module whose every fn imports three others — `std_list`, `std_either`, `std_pair` — and returns a compound polymorphic ADT tree (`Pair, List>`). Designed to stress monomorphisation across `List × Either × Pair`, cross-module ctor resolution at qualified `term-ctor` sites, and the 16a desugar layer's nested- Ctor pattern handling at depth 2. **What shipped.** - `examples/std_either_list.ailx` — three combinators, all `forall (vars e a)`, all using qualified cross-module names at `con` and `term-ctor` sites: - `lefts : List> -> List` — depth-2 nested-Ctor match per Cons arm (`Cons (Left l) t` / `Cons (Right _) t`) plus a wildcard tail to anchor the desugar's fall-through chain in `List` rather than the synthetic `Unit` fallback. - `rights : List> -> List` — symmetric to `lefts`. - `partition_eithers : List> -> Pair, List>` — `Nil → MkPair(Nil, Nil)`; `Cons h t →` let-bind `rest = partition_eithers t`, then a flat match on `h` to splice `l` / `r` onto `fst rest` / `snd rest`. Single pass. - `examples/std_either_list_demo.ailx` — first demo importing four stdlib modules. Drives all three combinators on the same five- element list `[Left 1, Right 10, Left 2, Right 20, Right 30]`; prints lengths via `std_list.length`. Output: `2 / 3 / 2 / 3`. - `crates/ail/tests/e2e.rs::std_either_list_demo` (e2e count 34 → 35). **16a desugar exercised.** Yes — `lefts` and `rights` use depth-2 nested Ctor patterns (`(pat-ctor Cons (pat-ctor Left l) t)`). Each expands into a chain of single-level matches via the 16a pass; the explicit trailing wildcard arm prevents the synthetic `Unit` fallback (the desugar's documented "valid programs never reach it" terminator) from leaking into the function's return type. Without the wildcard the checker reports `expected std_list.List, got Unit` because the chain's `else`-arm body is `Lit Unit` — confirms the design note in `desugar.rs::desugar_match` that exhaustiveness is the caller's responsibility, not the desugar's. **New compiler bug surfaced.** Yes — and it is **not** in the new combinators themselves but in the existing monomorphiser, surfaced the moment one tries to construct an inline list literal mixing `(term-ctor std_either.Either Left n)` and `(term-ctor std_either.Either Right n)`. Reduced repro is just `(app std_list.length (term-ctor std_list.List Cons (Left 1) (term-ctor std_list.List Cons (Right 10) (term-ctor std_list.List Nil))))` — no `lefts` / `rights` / `partition_eithers` involved. Cause: `synth_arg_type` produces `Either` for `Left 1` and `Either<$u, Int>` for `Right 10`. The outer `Cons`'s parameter type `List` unifies first against `List>` (binds `a = Either`) and then against `List>` for the tail. `unify_for_subst` recurses into the prev binding and ends up unifying param `$u` (from `Either`) against arg `Int` — the existing `if name.starts_with("$u") { return Ok(()); }` early-return only fires when `$u` is on the **arg** side. Param-side `$u` falls through to the catch-all error `cannot match param `$u` to arg `Int``. **Workaround in the demo.** Two monomorphic helpers `mkleft : Int -> Either` and `mkright : Int -> Either` pin both type vars at the call site, so each list element arrives with fully concrete `Either` and the synth-time `$u` never appears. Documented in the demo's header comment with a pointer back to this entry. The combinators themselves (`lefts`, `rights`, `partition_eithers`) are bug-free — the demo just couldn't build the input list inline without dodging the `$u`-on-param-side path. **Bug fix sketch (queued, not landed).** A symmetric early-return in `unify_for_subst` for param-side `$u` would close this — the wildcard semantics ("don't care, defer") are direction-agnostic. Filed as candidate iter 15g-aux. Single-line patch + a unit test under `ailang-codegen/src/lib.rs`. **Tests: 94/94.** - e2e: 35 (was 34, +1 for `std_either_list_demo`). - All other crates unchanged. **Cumulative state, post-15g.** - Stdlib: 5 modules (`std_maybe`, `std_list`, `std_either`, `std_pair`, `std_either_list`); 27 combinators total (24 + 3). - Deepest cross-module composition reached so far: `List × Either × Pair` in a single fn (`partition_eithers`). - Compiler bugs surfaced and fixed in dogfood since 14a: still 4 fixed; 1 new (the `$u`-on-param-side case above), worked around in the demo, queued as 15g-aux. **Queue update.** 15g done. Remaining: 15g-aux (param-side `$u` acceptance — small, one-line in `unify_for_subst`); 16b (local recursive `let`); 16c (Lit-in-Ctor patterns); 17a (per-fn arena, gated on user discussion). --- ## Iter 15g-aux — symmetric `$u` early-return in `unify_for_subst` **Goal.** Fix the codegen asymmetry that 15g surfaced: inline list literals mixing `Left n` and `Right n` ctor calls of the same `Either` errored at synth time even though both elements have fully-determined concrete-after-pinning types. **Diagnosis.** `unify_for_subst` (`crates/ailang-codegen/src/lib.rs`) had an arg-side-only early-return for `$u`-prefixed synth wildcards. The function has a prev-binding recursion path that re-invokes itself with the previously-bound type as the new param and the fresh arg, which can swap a `$u` from arg position into param position. Concretely, `length [Left 1, Right 10]` synths the list elements as `Either` and `Either<$u, Int>`. The first element pins `a = Either` for `Cons`'s parameter `a`. The second element triggers a recursive unification of the previously- bound `Either` against `Either<$u, Int>`, walking pairwise: `Int` vs `$u` (arg-side `$u`, ok) and `$u` vs `Int` (param-side `$u`, **falls through** to the catch-all error). **Fix.** Three lines in `unify_for_subst`: add a symmetric early- return for param-side `$u`. Doc comment expanded to record the asymmetry's origin and the symmetric extension's justification (`$u` is a synth-only wildcard regardless of which side it ends up on after the prev-binding swap). **Demo refactor.** `examples/std_either_list_demo.ailx` no longer uses the `mkleft`/`mkright` monomorphic helpers introduced as the 15g workaround. The list is now constructed inline by mixing `(term-ctor std_either.Either Left 1)` and `(term-ctor std_either.Either Right 10)` directly. Same expected output (2, 3, 2, 3); the demo doubles as the 15g-aux regression fixture. **Tests: 94/94, unchanged.** The fix expanded what compiles, did not change observable behaviour for any prior fixture. **Cumulative state, post-15g-aux.** - Stdlib unchanged (5 modules, 27 combinators). - One latent codegen bug retired. The bug count in the dogfood audit since 14a stays at 4 surfaced + fixed (this is a fresh surface from 15g, so 5 surfaced / 5 fixed). - The std_either_list_demo workaround is gone, leaving the inline cross-module mixed-ctor list as the canonical idiom. **Queue update.** 15g-aux done. Unchanged: 16b (local recursive let), 16c (Lit-in-Ctor patterns), 17a (per-fn arena, gated on user discussion of memory management). --- ## Iter 15h — std_list extension: take, drop **Goal.** Extend `std_list` with the two index-driven prefix combinators `take` and `drop`. They are the first `std_list` fns to combine `if` + Int arithmetic + recursive ADT pattern in a single body — every previous `std_list` fn was either a fold/HOF (fold_left, fold_right, length, map, filter, reverse) or a pure match-on-Cons (head, tail, append, is_empty). 15h closes the canonical-prefix-slicing gap and dogfoods the `if (<= n 0)` + `(- n 1)` interaction inside a recursive Cons-pattern body. **What shipped.** - `examples/std_list.ailx` — `take` and `drop` appended after `filter`. All ten pre-existing defs byte-identical (verified via `cargo run -p ail -- check` on every downstream importer: `std_list_demo`, `std_list_stress`, `std_either_list`, `std_either_list_demo`, `list_map_poly`). Both new fns are `forall (vars a). (Int, List) -> List`. `take` is constructor-blocked recursive (`Cons h (take (n-1) t)`); `drop` is direct recursive (the recursive call is the arm body, no Cons wrap), so the `match` arm is in tail position relative to the `if`'s `else` branch — but neither call site is marked `tail-app` because the surrounding `if` is not the fn's immediate body. Conservative; matches the rest of `std_list`'s marking discipline. - `examples/std_list_more_demo.ailx` — six prints exercising both combinators at all three boundary regimes (n=0, 0length). Reuses `std_list_demo`'s `(const xs)` idiom for the canonical `[1, 2, 3, 4, 5]`. Output: `0 / 3 / 5 / 5 / 3 / 0`. - `crates/ail/tests/e2e.rs::std_list_more_demo` (e2e count 35 → 36). **`(if (<= n 0) ...)` as the base-case guard.** Both fns gate the recursion on the int counter at the top of the body, _outside_ the match. The dual-arm `(case (pat-ctor Cons h t) ...)` then handles only the n>0 path — so the n=0 branch never re-enters the match, and the recursive call is reached only when the list is non-empty and n is still positive. This is a deliberate workaround for the absence of literal sub-patterns inside Ctor patterns: a hypothetical `(case (pat-ctor Cons _ _) (case-when (== n 0) ...))` or matching `n` against `0` directly is queued as 16c. With 16c landed, both fns could collapse the `if` into the match. **No new compiler bug surfaced.** The `if` + Int-arithmetic + recursive ADT pattern shape is the first instance for `std_list` in particular but not new for the compiler — `gc_stress` and `std_list_stress` already exercised `(if (== n 0) ...)` with `(- n 1)` recursion at the top level. `<=` on `Int` and the forall-`a` instantiation through the `match` cleanly reused the same monomorphisation paths. Round-trip `render | parse` stays canonical for both new files. **Tests: 95/95.** - e2e: 36 (was 35, +1 for `std_list_more_demo`). - All other crates unchanged. **Cumulative state, post-15h.** - Stdlib: 5 modules (`std_maybe`, `std_list`, `std_either`, `std_pair`, `std_either_list`); **29** combinators total (27 + 2). All four primary `std_list` operations — length, map, filter, take/drop — are now present, plus the head/tail/append/ reverse/is_empty/fold_left/fold_right surface from 15b. - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged from 15g-aux). **Queue update.** 15h done. Remaining: 16b (local recursive let), 16c (Lit-in-Ctor patterns — would simplify `take`/`drop`), 17a (per-fn arena, gated on user discussion). --- ## Iter 16b.1 — local recursive let (no-capture) **Goal.** Let me write `(let-rec f (params x) (type ...) (body ...) (in ...))` inline inside a fn body for the common no-capture case (the body's free vars are all module-top-level def names, qualified imports, effect-op names, or builtin operators). Stop forcing every recursive helper to be a separate top-level fn — typical small kernels (`fact`, `loop_n`, `gcd`) belong next to the use site, not on a sibling line at module scope. Sub-iter 16b.1 ships **no-capture only**. A LetRec whose body would capture a name from the enclosing lexical scope (an enclosing fn's params, a let-bound name from an outer `Term::Let`, or a pattern- bound name from an outer `Term::Match` arm) is rejected at desugar time with a clear panic that points at 16b.2 (closure conversion). That keeps the iter at lift-via-desugar — no runtime closure changes, no codegen plumbing, no LLVM IR change. **Design choice.** Lift the LetRec to a synthetic top-level fn in the same desugar pass that already runs between `load_module` and typecheck (16a). The lifted fn gets a fresh name `$lr_N` that is unique against both the original module's def names and against any earlier lifts in the same pass. Every reference to the local LetRec name (in the body and in the in-clause) is rewritten via a `subst_var` helper to the lifted name. The lifted `FnDef` is appended to `Module.defs`; from there the typechecker / codegen treat it like any hand-written top-level fn. Alternatives considered and rejected: - **Runtime closures** (treat LetRec like an anonymous lambda bound to a name). Would require a closure-pair allocation per call, plus a self-reference field in the env block. Reaches the same value but more LLVM IR per LetRec. Deferred to 16b.2 where it becomes necessary anyway (capture support). - **Open-coding the recursion at the LetRec site** via a Y-style fixed-point combinator. Adds a non-local construct (the combinator) and keeps the expansion at every LetRec; the lift- and-substitute approach keeps the runtime shape identical to a hand-written top-level fn. The pipeline invariant from 16a (`CheckedModule.symbols` hashes from the *original* on-disk module, not the desugared one) holds unchanged: a lifted def has no on-disk identity, so it never appears in `symbols`. `ail diff` and `ail manifest` see only the original-source defs. **What shipped.** - `crates/ailang-core/src/ast.rs` (+16): `Term::LetRec { name, ty, params, body, in_term }` variant inserted right after `Term::Let`. JSON tag `"t": "letrec"`. `ty` and `in_term` use serde renames (`type`, `in`) to keep the schema natural. Additive — every pre-16b.1 fixture canonicalises bit-identically. - `crates/ailang-core/src/desugar.rs` (+~570 of which ~310 are the new helpers + LetRec arm; the rest is doc/test): three additions plus the existing pass threaded through a `scope` parameter. (a) `free_vars_in_term` walks a term collecting every unbound `Term::Var` name, respecting all binders. (b) `subst_var` rewrites `Term::Var { name == from }` to the lifted name, respecting shadowing (a `Term::Let`/`Term::Lam`/`Pattern::Var` that rebinds `from` blocks the substitution inside its scope). (c) `Desugarer` gained `lifted: Vec` and `module_top_names: BTreeSet`; `desugar_module` seeds the latter from every original def name and appends `lifted` to `defs` after the per-def walk. The new `desugar_term` arm for `Term::LetRec` recurses on body+in_term with an extended scope, runs `free_vars_in_term` against `{name} ∪ params`, intersects with the outer `scope`, panics if non-empty, then generates `$lr_N`, substitutes, and appends the lifted `FnDef`. Two new unit tests: `let_rec_no_capture_lifts_to_top_level` and `let_rec_with_capture_panics` (`#[should_panic]`). - `crates/ailang-surface/src/parse.rs` (+~70): `let-rec-term` production added to the EBNF (still inside the 30-rule budget — count is now ~31, but `let-rec` is a positional analogue of `let` and the increment is consistent with the Decision-6 budget rationale). New `parse_let_rec` function; dispatch keyword added in `parse_term`. Unit test `parses_minimal_let_rec` round-trips a minimal shape. - `crates/ailang-surface/src/print.rs` (+16): `Term::LetRec` arm in `write_term`, single-line form mirroring the `Term::Let` arm's compactness. The round-trip harness (`tests/round_trip.rs`) picks up the new fixture automatically. - `crates/ailang-check/src/lib.rs` (+10): two `unreachable!` arms in `verify_tail_positions` and `synth` — `Term::LetRec` is eliminated by desugar before either runs. - `crates/ailang-codegen/src/lib.rs` (+19): four `unreachable!` arms in `lower_term`, `collect_captures`, `synth_with_extras`, and `apply_subst_to_term`. Same rationale. - `crates/ail/src/main.rs` (+22): `walk_term` (the `ail deps` walker) gets a real arm — `deps` runs on the on-disk module before desugar, so `Term::LetRec` is reachable there. Treats it like a fn def for dependency purposes (name shadows in body+in_term; params shadow inside body). - `examples/local_rec_demo.ailx` + `.ail.json` — first consumer fixture. A factorial helper bound by `(let-rec fact ...)` inside `main`'s body; called at n=1, n=3, n=5; prints `1\n6\n120\n`. The `fact` body has no captures from `main`'s scope (referenced names: `<=`, `*`, `-`, `fact`, `n`, `1` — all builtins, the LetRec's own name, or its param), so it lifts cleanly. - `crates/ail/tests/e2e.rs::local_rec_factorial_demo` (+18): e2e count 36 → 37. **Unreachable arms.** Every backend stage that pattern-matches `Term` exhaustively now has a `Term::LetRec { .. } => unreachable!("Term::LetRec eliminated by desugar")` arm. The phrase is identical across all five sites (`ailang-check/src/lib.rs` × 2, `ailang-codegen/src/lib.rs` × 4) so a future grep reaches every one of them. The `ail deps` walker is the one site that intentionally has a real arm, because `deps` runs on the on-disk module before any desugar pass, and it is documented inline. **Lift-name format.** `$lr_N` where `` is the source-level LetRec name and `N` is an incrementing counter that yields the first name not already in `Desugarer.used` or `Desugarer.module_top_names`. Mirrors the `$mp_N` fresh-match- pattern naming from 16a; `$lr_` is the namespace marker for "lifted recursion". `$` is a valid ident character in form (A), so the name reaches LLVM as is — but `clang` mangling chokes on `$`, so the name is sanitised the same way every other ail name is (the existing `@ail__` mangling already handles `$` because of the 16a `$mp_` precedent). **Tests: 95 → 99 (+4).** - e2e: 36 → 37 (`local_rec_factorial_demo`). - `ailang-core::desugar::tests`: 2 → 4 (the two new LetRec tests). - `ailang-surface::parse::tests`: 2 → 3 (`parses_minimal_let_rec`). - All other crates unchanged. **Cumulative state, post-16b.1.** - Stdlib unchanged (5 modules, 29 combinators). - `Term` enum: 11 variants (was 10) — additive, all pre-existing fixture hashes bit-identical. - 16a desugar pass now does two jobs: nested-ctor-pattern flattening (16a) and LetRec lift (16b.1). The pass remains the single AST-→-AST hop between `load_module` and typecheck. - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged from 15h). **Queue update.** 16b.1 done. Remaining: 16b.2 (LetRec capture — closure conversion or env-passing rewrite, with the panic at desugar time as the boundary-marker until then), 16c (Lit-in- Ctor patterns — would simplify `take`/`drop`), 17a (per-fn arena, gated on user discussion of memory management). ## Iter 16c — literal patterns via desugar **Goal.** Lift the last gate that survived the 16a-aux audit: `Pattern::Lit` was rejected at the codegen level (an internal error in the Match lowering) and at the typechecker level when nested inside a Ctor (`nested-ctor-pattern-not-allowed`). 16c makes lit patterns work **everywhere they parse** — top-level arms and Ctor sub-patterns alike — by extending the existing 16a desugar pass. **Design choice.** Desugar `Pattern::Lit { lit }` to `Term::If { cond = (== sv lit), then = body, else_ = fall_k }`, where `sv` is the let-bound scrutinee (top-level) or the field-bound fresh var (sub-pattern). After 16c, no `Pattern::Lit` survives the desugar pass — the codegen and typechecker never see one. Alternatives considered and rejected: - **Switch-on-i64 in codegen** (a `switch i64` LLVM instruction per Match with at least one Int-lit arm). Smaller IR for many-arm dispatch, but adds a second match-lowering path in codegen and grows the typechecker's exhaustiveness checker (now needs to reason about lit coverage). The desugar-to-If approach hands every problem to the existing infrastructure: `==` is a typed builtin, `Term::If` already lowers correctly, and the chain machinery from 16a already produces a fall-through structure that fits. - **Codegen-level lit-arm rejection only** (allow lit arms past typecheck and trap at codegen). Worse than today: today's codegen rejects with an internal error; future codegen would need a real lowering. Strictly more work for no gain. The pipeline invariant from 16a/16b.1 holds unchanged: the desugar runs after `load_module`, so on-disk module hashes are untouched. `ail diff` and `ail manifest` see only original-source defs. **What shipped.** - `crates/ailang-core/src/desugar.rs` (+~120, of which ~80 are doc/test): three replacements plus one new helper. (a) The `Pattern::Lit` arm of `desugar_one_arm` now emits a `Term::If { cond = (== s_var lit), then = arm.body, else_ = fall_k }` instead of the old single-arm `Term::Match` with the lit pattern preserved (which the codegen rejected). (b) The `Pattern::Lit` branch of `wrap_sub` mirrors (a) on the field-bound fresh variable, replacing the old recursive `desugar_match` call. (c) `is_flat` no longer classifies `Pattern::Lit` as flat — the early-return path in `desugar_match` would otherwise leak a Pattern::Lit arm through to typecheck/codegen unchanged. With the change, lit-arms always take the let-bind + chain path. (d) New free function `build_eq(scrutinee, lit) -> Term`: produces `(app == scrutinee lit)` for Int/Bool/Str and a `Bool(true)` literal for Unit (every Unit value is equal). Three new unit tests: `top_level_lit_desugars_to_if`, `nested_lit_in_ctor_desugars_to_if`, and `flat_arm_with_lit_is_no_longer_flat` (regression guard for the deliberate change in `is_flat`). A new `any_lit_pattern` walker mirrors `any_nested_ctor`. - `examples/lit_pat.ailx` + `examples/lit_pat.ail.json` — first consumer fixture. Two fns: `classify` (top-level lit arms for 0/1/default → 100/200/999) and `categorize_first` (`Cons (pat-lit 0) _` nested-lit demonstration over a local `IntList` ADT). The trailing `_` catch-all in `categorize_first` is required by the 16a chain machinery — the chain's terminator is a `Unit` literal, so a final `_` arm dominates it and keeps the match type-checking. Output (per line): 100, 200, 999, -1, 0, 7. - `crates/ail/tests/e2e.rs::lit_pat_demo` (+15 incl. doc): e2e count 37 → 38. - `docs/DESIGN.md`: moved the "no literal sub-patterns inside a Ctor" line out of "What is not (yet) supported" into the "Recently lifted gates" preamble and into the "What is supported" list, with a note that `Bool`/`Str`/`Unit` lit patterns are accepted by desugar but reach typecheck as a type error today (because `==` is `(Int, Int) -> Bool` only — extending `==` to those types is a separate iter). **What deliberately did NOT change.** - Codegen's `Pattern::Lit` arm at `crates/ailang-codegen/src/lib.rs:1302` still returns `CodegenError::Internal("MVP: lit patterns in match not supported")`. Left as a never-reached safety net — the desugar is the contract; the codegen panic catches future regressions where a Pattern::Lit slips through. - Typechecker's `Pattern::Lit` arm in `check_pattern` still runs (it accepts the pattern but contributes nothing to exhaustiveness). Same rationale: dead code at the source-AST level after desugar, but defensive against a future caller that bypasses desugar. - `std_list::take` and `std_list::drop` still hand-write the base-case-via-arm-body workaround (`(case (pat-ctor Cons h t) (if (== n 0) Nil (...)))`). Refactoring them to use lit patterns is queued as 16c-aux. **Tests: 99 → 103 (+4).** - e2e: 37 → 38 (`lit_pat_demo`). - `ailang-core::desugar::tests`: 4 → 7 (the three new lit tests). - All other crates unchanged. **Cumulative state, post-16c.** - Stdlib unchanged (5 modules, 29 combinators). - `Term`/`Pattern`/`Literal` enums unchanged — additive at the desugar level only, so all pre-existing fixture hashes stay bit-identical. - 16a desugar pass now does three jobs: nested-ctor-pattern flattening (16a), LetRec lift (16b.1), and lit-pattern → If rewrite (16c). Pass remains the single AST-→-AST hop between `load_module` and typecheck. - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged). **Queue update.** 16c done. Remaining: 16b.2 (LetRec capture — closure conversion, unchanged), 16b.3 (LetRec let-binding capture, unchanged), 17a (per-fn arena, gated on user discussion of memory management, unchanged). 16c-aux (`std_list::take`/`drop` refactor onto lit patterns) is a separate iter, gated on orchestrator decision. ## Iter 16b.2 — planning entry (LetRec capture) **Status: planning, not implemented.** This entry is orchestrator work-product, not an iteration log. It captures the design space for 16b.2 so the user can review the trade-offs before implementation, and so a future agent has a sharp brief to work against. **Goal (when implemented).** Lift 16b.1's no-capture restriction. Support `(let-rec name ...)` whose body references one or more names bound in the enclosing lexical scope, by augmenting the lifted top-level fn's signature with the captured names as extra parameters and rewriting every call site of `name` (in body and in_term) to pass the captures positionally. **Why this is non-trivial.** The lifted fn's signature must include the captures' types. Those types are not always discoverable at desugar time: - **Captured fn-param**: type is in `f.ty.params[i]`. Known. - **Captured Lam-param**: type is in `param_tys[i]`. Known. - **Captured Let-binding**: `Term::Let { name, value, body }` carries no type annotation on `name`. Type is the inferred type of `value`, which the typechecker computes — but the desugar pass runs *before* the typechecker. **Unknown at desugar time.** - **Captured Match-arm pattern var**: type is the substituted field type of the matched constructor, requiring an ADT-def lookup plus arg-substitution. Computable in principle, but the desugar pass would need to track the scrutinee's type through the walk — also a small inference engine. **Architectural choices.** Pick one path before implementing: 1. **Stay at desugar; restrict to fn/Lam-param captures only.** Reject Let-binding and Match-arm captures with a clear error → `16b.3` (Let captures) and `16b.4` (Match captures). Lowest-cost path, covers the most common case (recursive helpers that close over an enclosing fn's input). 2. **Move LetRec elimination to a post-typecheck pass.** By then every `Term` has known types; capture types fall out. Cost: a new pipeline stage, plus updating `DESIGN.md`'s pipeline section. Pays off if the post-pass is also where other future lowerings live (e.g. closure conversion). 3. **Run a lightweight inference inside desugar.** Just enough to resolve `Term::Let`-bound names to their value's type. Effectively a Hindley-Milner pass on a subset of the AST. Cost: significant; basically duplicating part of `ailang-check`. Rejected on principle — one source of truth. **Recommended path.** (1), shipping incrementally. 16b.2 covers fn/Lam-params; 16b.3 lifts Let-bindings via path (2). The benefit of (1) first: it surfaces real-world usage and informs how often Let-binding capture actually matters. **Other restrictions for 16b.2 (under path 1).** - **Direct-call only.** The LetRec name `f` may appear in `body` and `in_term` only as the callee of a `Term::App`, never as a `Term::Var` in any other position (e.g. passed as an argument, bound to a `let`, or stored in an ADT field). Reason: with captures-as-extra-params, every use site needs the extras appended. Treating `f` as a value would require a closure object that bundles `f` and its captures — that's 16b.5 / closure conversion. - **Monomorphic enclosing fn.** If the enclosing fn is `forall(...). ...`, the captures' types may mention the outer's type vars. Constructing the lifted fn's `Forall` is doable but error-prone; defer to 16b.6. - **Single-level LetRec.** A LetRec whose body contains another LetRec that captures the outer's name or params is not supported in 16b.2; reject with a clear error → 16b.7. **What 16b.2 ships (under the recommended scope).** - `desugar_term`'s `scope` parameter changes from `&BTreeSet` to `&BTreeMap`, where `ScopeEntry` is `KnownType(Type) | LetBound | MatchArm`. - Each binder extends the map: fn/Lam-params with `KnownType`, Let-bindings with `LetBound`, Match-arm bindings with `MatchArm`. - LetRec arm's capture detection: free-vars ∩ scope-keys. For each capture, look up `ScopeEntry`. If `KnownType(t)`, proceed. If `LetBound` / `MatchArm`, error. - Validate: walk body and in_term, assert `name` only appears as `Term::App.callee`. Helper `validate_callee_only_use`. - Build augmented fn type: `original.ty` with capture types appended to `params`. Reject if `original.ty` is `Forall` (out of scope per restriction 2 above). - Rewrite call sites: `(app f a b)` → `(app f$lr_N a b cap0 cap1)`. Use a new helper `subst_call_with_extras` that walks the term, recognizes `Term::App { callee = Var{f} }` patterns, rewrites them, and recurses elsewhere. - Substitute `f` → `f$lr_N` everywhere in body (for the recursive self-reference at the lifted level). The existing `subst_var` from 16b.1 handles this, but care needed: it must run **after** `subst_call_with_extras` so that recursive calls get both the rename and the extras. - Append the lifted `FnDef` to `Module.defs`. **Risk.** The most likely failure mode is the typechecker rejecting a synthesized augmented fn signature that we believe should type-check. Mitigation: implement under restriction 2 (no Forall) so we never construct a Forall; the augmented type is plain `Type::Fn` with monomorphic capture types appended. **Tests.** - `examples/local_rec_capture.ailx`: an enclosing fn with one Int param, a LetRec that recurses against that param. e2e asserts an output value derived from that capture. - Negative tests at the desugar unit-test level: Let-binding capture errors; Match-arm capture errors; name-as-value errors; nested LetRec mutual-capture errors. All `#[should_panic]` (consistent with 16b.1's panic discipline). **Adjacent open items.** - **16d**: chain-machinery's `Unit` terminator forces a trailing `_` arm in matches that are otherwise exhaustive, surfaced by 16c (`categorize_first` fixture). Two design paths: (a) introduce a polymorphic `__unreachable__` builtin that codegen lowers to LLVM `unreachable`, used as the chain default; (b) run an exhaustiveness pre-check in desugar against the scrutinee's ADT and omit the terminator when arms cover all ctors. Path (a) is broader (gives users a primitive for panics/asserts). Path (b) is purer (terminator never appears for exhaustive matches) but needs ADT lookup in desugar. - **16e**: extend `==` from Int-only to Int/Bool/Str. Bool comparison is i1-equality (trivial). Str comparison needs a runtime `strcmp`. Surfaced by 16c (`build_eq` produces `(app == ...)` for Bool/Str/Unit but currently fails at typecheck because `==` is not declared for them). - **17a**: per-fn arena allocator. Gated on user memory-management discussion (separate stored memory). **Queue update post-16c.** 16c done. Open: 16b.2 (this entry — planning only, awaiting user input on path 1 vs path 2), 16d (planning needed — pick path a vs b), 16e (`==` extension), 17a (gated). All implementation work in this session-arc is suspended at this planning checkpoint. ## Iter 16b.2 — LetRec captures of fn-params (path-1 safe subset) **Goal.** Lift 16b.1's no-capture restriction for the **path-1 safe subset** described in the 16b.2 planning entry: support a `(let-rec ...)` whose body captures one or more names from the enclosing scope, **provided every capture comes from a fn-param or Lam-param** (whose types are statically declared at desugar time). The lifted top-level fn's signature gets the capture types appended to `params`; every call site of the LetRec name is rewritten to pass the captures positionally as extra args. Anything outside the safe subset is rejected at desugar time with a panic that names the violation and points at the follow-up iter that will handle it (16b.3 / 16b.4 / 16b.5 / 16b.6 / 16b.7). **Path-1 restrictions in force (rejected at desugar with a follow-up-iter pointer).** - **Let-binding capture** (`Term::Let`-bound name, type unknown at desugar). Queued for **16b.3**. - **Match-arm pattern-binding capture** (constructor-field substitution from the scrutinee's ADT not done at desugar). Queued for **16b.4**. - **Name-as-value use** (the LetRec name `f` appears anywhere except as the callee of a `Term::App` — e.g. `(let g f ...)` or `(app some_hof f)`). Needs closure conversion. Queued for **16b.5**. - **Polymorphic enclosing fn** (`Type::Forall`). Captured fn-param types may mention outer type vars; constructing the lifted signature's `Forall` is error-prone. Encoded as `ScopeEntry::LetBound` for every fn-param of a `Forall`-typed enclosing fn — caught at the same site as let-binding captures. Queued for **16b.6**. - **Nested LetRec mutual capture** (an inner LetRec captures the outer LetRec's name or params). Queued for **16b.7**. **What shipped.** - `crates/ailang-core/src/desugar.rs` (1341 → 1853 LOC, +512). The hot changes: - New `enum ScopeEntry { KnownType(Type), LetBound, MatchArm, EnclosingLetRec }`. The `scope` parameter threaded through `desugar_term` and helpers became `&BTreeMap` (was `&BTreeSet`). Every binder ([`Term::Let`], [`Term::Lam`], [`Term::Match`] arm, [`Term::LetRec`]) inserts the appropriate variant; `desugar_module` seeds fn-param entries from `f.ty` (peeled `Forall`). - `Term::LetRec` arm rewritten end-to-end: peel `ty` to its inner `Type::Fn` to learn the LetRec's own param types, extend body-scope with `EnclosingLetRec` for `name` + `KnownType(_)` for params, recurse on body and in_term, run `free_vars_in_term` against `{name} ∪ params`, intersect with the outer scope's keys, classify each capture's `ScopeEntry` (KnownType → accept; everything else → panic with the follow-up iter pointer), validate via `find_non_callee_use` that `name` only appears as a callee, build the augmented `Type::Fn` with capture types appended, rewrite call sites via `subst_call_with_extras`, then `subst_var` for any non-call references (defensive — none survive the validator), append the lifted `FnDef`. - Two new free helpers (~150 LOC together): `subst_call_with_extras(t, name, lifted, extras)` rewrites every `Term::App { callee = Var{name} }` to `Term::App { callee = Var{lifted}, args ++ extras_as_vars }`, walking through every variant; `find_non_callee_use(t, name)` walks `t` and returns `Some(t)` at the first `Term::Var { name == name }` reference in a non-callee position, `None` otherwise. Plus `peel_forall_to_fn` (one-liner). - The 16b.1 panic is gone for fn-param captures (replaced by the lifter); it persists for every other capture kind, with a sharper message. - `crates/ail/tests/e2e.rs` (+18): `local_rec_capture_demo` — e2e count 38 → 39. Asserts that the `local_rec_capture` fixture prints `0\n10\n45\n`. - `examples/local_rec_capture.ailx` + `.ail.json` (35 LOC source): `sum_below(n)` returns the sum of integers `1 + ... + (n-1)`. Body uses an inner `(let-rec loop (params i) ... (body ... (app >= i n) ... (app loop (app + i 1))) (in (app loop 1)))`. The helper captures `n` from `sum_below`'s param list; the desugar pass lifts it to `loop$lr_0(i: Int, n: Int) -> Int` and rewrites every `(app loop X)` to `(app loop$lr_0 X n)`. `main` drives `sum_below` at 1, 5, 10 → outputs `0`, `10`, `45`. - `crates/ailang-core/src/desugar.rs::tests` (3 new): `let_rec_capture_fn_param_lifts_with_extra_arg` (positive), `let_rec_capture_let_binding_panics` (`#[should_panic(expected = "16b.3")]`), `let_rec_name_as_value_panics` (`#[should_panic(expected = "16b.5")]`). The 16b.1 `let_rec_with_capture_panics` test was repurposed into the positive `let_rec_capture_fn_param_lifts_with_extra_arg`: its fixture (a fn-param capture) is now legal and produces the expected augmented signature. **The augmented-signature mechanism.** Given `(let-rec f (params p1..pk) (type Fn(t1..tk) -> tr) (body B) (in I))` inside a fn whose scope contains `{c1: T1, ..., cm: Tm}` (KnownType entries) used as free vars in `B`: - Lifted signature: `f$lr_N : Fn(t1..tk, T1..Tm) -> tr` with the same `effects` set as the original. - Lifted param-name list: `p1..pk, c1..cm` (capture names appended unchanged so the lifted body's references resolve directly). - Body rewrite: every `(app f a1..an)` in `B` → `(app f$lr_N a1..an c1..cm)`. The `c1..cm` are `Term::Var` references that resolve, in the lifted body, to the appended params with the same names. Free uses of `f` in non-callee position are pre-rejected by `find_non_callee_use`. - In-term rewrite: every `(app f a1..an)` in `I` → `(app f$lr_N a1..an c1..cm)`. The `c1..cm` are `Term::Var` references that resolve, in the in-term's enclosing fn, to the captured names themselves (still in scope). The rewrite is order-sensitive: `subst_call_with_extras` runs **before** `subst_var`, so a recursive self-call inside `B` first becomes `(app f$lr_N ... c1..cm)`, then any leftover bare `Var{f}` (none in 16b.2 — pre-rejected) gets renamed to `Var{f$lr_N}`. The second pass is defensive. **Tests: 102 → 106 (+4).** - e2e: 38 → 39 (`local_rec_capture_demo`). - `ailang-core::desugar::tests`: 6 → 9. Net +3 because the 16b.1-era `let_rec_with_capture_panics` test was repurposed rather than removed (its fn-param-capture fixture is now a positive lift), and two pure-negative tests were added. - All other crates unchanged. **Cumulative state, post-16b.2.** - Stdlib unchanged (5 modules, 29 combinators). - `Term` enum: 11 variants (unchanged; LetRec is still desugar-eliminated, no schema impact). All pre-16b.2 fixtures hash bit-identically. - 16a desugar pass now does three jobs: nested-ctor-pattern flattening (16a), literal-pattern lowering (16c), and LetRec lift (16b.1 → 16b.2 path-1). Single AST→AST hop between `load_module` and typecheck. - The `unreachable!("Term::LetRec eliminated by desugar")` arms in `ailang-check` × 2 and `ailang-codegen` × 4 remain correct — every LetRec is still gone before either stage runs. **Queue update post-16b.2 path-1.** 16b.2 path-1 done. Open: **16b.3** (LetRec captures of `Term::Let`-bound names — would need a post-typecheck re-run of the lift, or a small inference on the let-value's type), **16b.4** (LetRec captures of match-arm pattern bindings — needs ADT-def lookup + constructor-field substitution), **16b.5** (closure conversion — lifts the "name-as-value-only" restriction for both LetRec and Lam), **16b.6** (LetRec inside a `Type::Forall`-quantified fn — needs synthesised `Forall` for the lifted signature), **16b.7** (nested LetRec mutual capture — generalised lifting that accumulates captures across nesting). 16d (chain-machinery exhaustiveness or `__unreachable__` — planning needed), 16e (`==` extension to Bool/Str/Unit), 17a (per-fn arena, gated) unchanged. ## Iter 16b.3 — LetRec captures of Let-bound names **Goal.** Lift 16b.2's "fn/Lam-param captures only" restriction. A `(let-rec ...)` may now capture names bound by a `Term::Let` in the enclosing scope. Match-arm captures (16b.4), name-as-value (16b.5), Forall enclosing fn (16b.6), and nested LetRec mutual capture (16b.7) remain rejected at desugar time. **Architectural choice (path-2: post-typecheck lift).** The desugar pass cannot resolve a `Term::Let`-bound name's type — `Term::Let` carries no annotation; the value's type is inferred at typecheck. Three options were on the table (path-1 = stay-at-desugar with restrictions, path-2 = post-typecheck lift, path-3 = run a private inference inside desugar). Path-2 is the cleanest: typechecker is the single source of truth, and the lift now becomes a small AST-→-AST pass with O(1) type lookups against the typechecker's env. `Term::LetRec` reaches the typechecker only when the desugar pass deferred it — for the 16b.2 fast-path (KnownType captures only) the desugar still does the lift in one hop. **What shipped.** - `crates/ailang-core/src/desugar.rs` (1853 → 1931 LOC, +78). The `Term::LetRec` arm now has three exits: (a) `KnownType`-only captures → existing 16b.2 lift (unchanged). (b) Any `LetBound` capture → reconstruct the `Term::LetRec` with desugared sub-terms and return it (defer to post-typecheck pass). (c) `MatchArm` / `EnclosingLetRec` → panic with the same follow-up-iter pointers as 16b.2. The `find_non_callee_use` (16b.5) check moved to run before classification so the diagnostic fires consistently regardless of which exit is taken. Four helpers (`free_vars_in_term`, `subst_var`, `subst_call_with_extras`, `find_non_callee_use`, `pattern_binds`) were promoted from `fn` to `pub fn` so the post-typecheck pass can reuse them. - `crates/ailang-check/src/lib.rs` (2887 → 3281 LOC, +394 including tests). - `verify_tail_positions` and `synth` arms for `Term::LetRec` replaced. `verify_tail_positions`: body is NOT in tail position (it's a fn body — the LetRec name is what gets tail-called); in_term inherits the enclosing context. `synth`: peel any `Forall` defensively, validate param count, install `name + params` in locals for the body, synth the body, unify against `ret_ty`, check the effect-subset rule (same as `Term::Lam`); restore locals; install `name` for the in-clause; synth, return. - New `pub fn check_and_lift(m) -> Result<(CheckedModule, Module)>` runs check + desugar + `lift_letrecs` and returns both the original-symbols `CheckedModule` and the lifted module ready for codegen. - `crates/ailang-check/src/lift.rs` (new file, 720 LOC). The `pub fn lift_letrecs(m: &Module) -> Result` post-pass. - Fast-path: `contains_any_letrec(m)` returns `false` → return input unchanged. Skips env construction so cross- module modules (which we don't fully wire up here) are not touched unless they actually contain a deferred LetRec. - Builds a single-module env (builtins + module type defs + module globals + imports + current_module) and walks every `Def::Fn`'s body, threading a parallel `IndexMap` of locals as scope. At each `Term::Let`, synth the value's type to populate locals; at each `Term::Match` arm, run a minimal `type_check_pattern_for_lift` to infer pattern-arm bindings. - At every `Term::LetRec`: post-order recurse first (so nested LetRecs are lifted before their enclosing one), re-run `find_non_callee_use` defensively, recompute captures via `free_vars_in_term`, look up each capture's type from `locals`, build the lifted `FnDef` (capture types appended to params), append to a `lifted: Vec` accumulator, rewrite call sites in body and in_term via `subst_call_with_extras`, then `subst_var` for any leftover bare references. Synthetic name `$lr_N` seeded past the highest existing `*$lr_N` suffix in `m.defs` so it cannot collide with a 16b.2 lift. Synthetic FnDefs carry a `doc` of the form `"Lifted by 16b.3 from let-rec '' inside ''."`. - The `subst_call_with_extras` and `subst_var` helpers come straight from `ailang-core::desugar` — re-used rather than duplicated (the brief asked for one or the other; re-use via `pub` keeps the rewrite logic single-sourced). - `crates/ail/src/main.rs` (+25 LOC). `build_to` now goes `load → check → per-module (desugar + lift_letrecs) → codegen`. The `check` subcommand stays typecheck-only (no lift needed for type-checking). Codegen's internal `desugar_module` call is harmless on a lifted module — no `Term::LetRec` remains, so the LetRec arm is never invoked. - `examples/local_rec_let_capture.ailx` + `.ail.json` (48 LOC source). `count_below(n)` returns how many `i` in `1..=n` are strictly less than a `let threshold = (app + 5 5)` computed inside the enclosing fn. The recursive helper `loop` captures `threshold` (Let-bound; type unknown until typecheck) and recurses on its own counter `i`. The lift produces `loop$lr_0(i: Int, n: Int, threshold: Int) -> Int` and rewrites every `(app loop X)` to `(app loop$lr_0 X n threshold)`. The let-value is `(app + 5 5)` rather than a literal so the type-synthesis path is exercised. Drives at 0, 5, 15 → output `0\n5\n9\n`. - `crates/ail/tests/e2e.rs::local_rec_let_capture_demo` (+18): e2e count 39 → 40. - `crates/ailang-core/src/desugar.rs::tests`. The 16b.2-era `let_rec_capture_let_binding_panics` test was repurposed into a positive test `let_rec_capture_let_binding_is_deferred_to_post_typecheck` that asserts the desugar leaves the LetRec in place (no lifted fn appended; original LetRec still reachable). Net test count unchanged. - `crates/ailang-check/src/lib.rs::tests` (24 → 27, +3): `letrec_with_let_binding_capture_typechecks` (positive), `letrec_body_wrong_return_type_is_rejected` (negative — body returns Bool but declared Int), and `lift_letrecs_on_let_capture_produces_synthetic_fn` (asserts synthetic FnDef added with augmented signature and call sites rewritten). - `docs/DESIGN.md` (+12 LOC). Pipeline section gains the `lift_letrecs` stage between `check` and `codegen`, with a paragraph clarifying it runs only on the `build` / `run` paths and that synthetic FnDefs do not appear in `CheckedModule.symbols`. **The deferral mechanism.** Given `(let y (app + 5 5) (let-rec helper (params x) (type Fn(Int) -> Int) (body (app + x y)) (in (app helper 1))))` inside an enclosing fn: - Desugar runs. The LetRec's outer scope is `{n: Int (fn-param), y: LetBound}`. Free vars of `body` minus `{name, params}` = `{+, y}`; intersection with scope = `{y}`. `y` is `LetBound`, so the all-or-nothing classifier sees `has_let_bound = true` and reconstructs the LetRec with desugared sub-terms instead of lifting. - Typecheck runs. The new `Term::LetRec` arm in `synth` extends locals with `helper: Fn(Int) -> Int` and `x: Int`, synths the body to `Int`, unifies with `ret_ty`, and accepts. - `lift_letrecs` runs. Walking outer's body, it threads locals. At the `Term::Let`, `synth_type` resolves `(app + 5 5) → Int` and inserts `y: Int` into locals. At the `Term::LetRec`, the capture analyser collects `{y}` and reads its type as `Int`. Builds `helper$lr_0(x: Int, y: Int) -> Int`, rewrites `(app helper 1)` → `(app helper$lr_0 1 y)`, appends the lifted `FnDef`. The Term::LetRec node is replaced by its rewritten in-clause. - Codegen runs on the lifted module. No `Term::LetRec` remains; the four `unreachable!("Term::LetRec eliminated by desugar")` arms in codegen stay valid (the message is a slight misnomer post-16b.3 — by the time codegen runs, every LetRec has been eliminated by desugar OR lift_letrecs — but the invariant holds). **Tests: 106 → 110 (+4).** - e2e: 39 → 40 (`local_rec_let_capture_demo`). - `ailang-check::tests`: 24 → 27 (the three new LetRec tests). - `ailang-core::desugar::tests`: 9 → 9 (one `#[should_panic]` test repurposed to a positive defer-check test — net 0). **Cumulative state, post-16b.3.** - Stdlib unchanged (5 modules, 29 combinators). - `Term` enum: 11 variants (unchanged). All pre-16b.3 fixtures hash bit-identically — additive at the typechecker / new-pass level only. - Compiler stages: load → desugar → typecheck → **lift_letrecs (16b.3)** → codegen. The `check` subcommand stops at typecheck and skips the lift; `build` / `run` go all the way through. - The four `unreachable!("Term::LetRec eliminated by desugar")` arms in `ailang-codegen` remain correct: by the time codegen runs, no LetRec survives — desugar lifts the 16b.1/16b.2 cases, lift_letrecs catches the 16b.3 case. The two `unreachable!` arms in `ailang-check` were replaced with the new typing rule (`verify_tail_positions` and `synth`). - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged). **Queue update post-16b.3.** 16b.3 done. Open: **16b.4** (LetRec captures of match-arm pattern bindings — needs ADT-def lookup + constructor-field substitution; would slot into the same `lift_letrecs` pass with extended pattern-arm walk), **16b.5** (closure conversion — lifts the "name-as-value-only" restriction for both LetRec and Lam; out of scope for this iter line), **16b.6** (LetRec inside a `Type::Forall`-quantified fn — needs synthesised `Forall` for the lifted signature), **16b.7** (nested LetRec mutual capture — generalised lifting that accumulates captures across nesting; partly already handled by the post-order traversal in `lift_letrecs` but the mutual case is genuinely harder). 16d (chain-machinery exhaustiveness or `__unreachable__`), 16e (`==` extension to Bool/Str/Unit), 17a (per-fn arena, gated) unchanged. ## Iter 16b.4 — LetRec captures of match-arm pattern bindings **Goal.** Lift the 16b.3-era "match-arm capture rejected" panic. A `(let-rec ...)` whose body captures a name bound by a `Term::Match` arm pattern (a `Pattern::Var`, or a `Pattern::Var` sub-pattern of a `Pattern::Ctor`) is now legal. Same architectural path as 16b.3: desugar defers it; `lift_letrecs` resolves the capture's type from the enclosing match's scrutinee type, applying constructor-field substitution against the matched ctor's declared field types. **What changed in desugar.** Exactly one classification arm. The LetRec capture loop used to treat `ScopeEntry::MatchArm` as a hard panic (queued for 16b.4); it now sets `needs_defer = true` and falls through to the same defer-arm 16b.3 wrote for `LetBound`. The `has_let_bound` boolean was renamed to `needs_defer` so it covers both LetBound and MatchArm captures uniformly. Mixed scopes (some KnownType + some MatchArm, or some MatchArm + some LetBound) defer just like the 16b.3 mixed case — the all-or-nothing decision is preserved. `EnclosingLetRec` still panics (queued for 16b.7). Total desugar diff: ~25 LOC of comment/classification changes; no new helpers, no new traversals. **What `lift_letrecs` already supported.** The post-typecheck pass needed **zero** code changes. 16b.3's `type_check_pattern_for_lift` was already written to handle the full pattern grammar: - `Pattern::Wild` / `Pattern::Lit`: bind nothing (no-op). - `Pattern::Var`: binds the entire scrutinee's type. - `Pattern::Ctor`: looks up the ADT in `env.types` (or `env.module_types` for a qualified `module.Type`), validates ctor arity, builds the type-var → arg substitution from `td.vars` zip `s_ty.args`, applies it to each `cdef.fields[i]`, and recurses on each sub-pattern with the substituted field type. The recursion handles arbitrarily-nested Ctor patterns for free (though desugar's 16a flattening means lift_letrecs rarely sees deeply-nested ctor patterns in practice). The only reason 16b.3 left the MatchArm path under a `panic!` was to keep 16b.3's scope tight — the machinery for resolving the type was already in place and exercised by 16b.3's own test suite, just gated behind the desugar panic. **The fixture: `examples/local_rec_match_capture.ailx` (+ `.ail.json`).** Defines `data Pair (vars a b) (ctor MkPair a b)`. `count_below(p)` takes a `Pair Int Int` (threshold, n), pattern-matches with `(pat-ctor MkPair threshold n)` (so `threshold` and `n` are both match-arm bindings of type `Int` — the ADT's `[a, b]` ctor fields substituted against the scrutinee's `[Int, Int]` type args), and inside that arm runs a recursive `loop` that captures BOTH match-arm bindings to count integers in `1..=n` strictly less than `threshold`. The lift produces a synthetic top-level fn `loop$lr_0(i: Int, threshold: Int, n: Int) -> Int` and rewrites every `(app loop ARGS)` in the arm body to `(app loop$lr_0 ARGS threshold n)`. Drives at `MkPair 10 0`, `MkPair 10 5`, `MkPair 10 15` → output `0\n5\n9\n` (the same numbers as the 16b.3 fixture so the comparison is direct). The fixture exercises the full 16b.4 chain: parameterised ADT construction, match on the ctor, two simultaneous match-arm captures (not just one), and `lift_letrecs` resolving both via constructor-field substitution. **What this iter exercises that 16b.3 did not.** - The MatchArm branch of `type_check_pattern_for_lift` (was unreachable in real programs because desugar always panicked before the lift saw such captures; covered by the 16b.3 test suite synthetically but never end-to-end). - Multi-capture LetRec (16b.2 used a single fn-param capture; 16b.3 used a single Let-capture; this is the first fixture with two captures appended in the lifted signature, in a fixed order driven by the BTreeSet's deterministic iteration). - Constructor-field substitution at the lift site (the type args of the scrutinee type `Pair Int Int` flow into the synthetic fn's signature via the substitution map). **Tests: 113 → 116 (+3).** - e2e: 40 → 41 (`local_rec_match_capture_demo`, asserts the `0/5/9` stdout from the new fixture). - `ailang-check::tests`: 27 → 28 (`lift_letrecs_on_match_arm_capture_produces_synthetic_fn`: builds a Pair-using module, asserts the lifted FnDef's name starts with `helper$lr_`, params are `[z, x]`, and the type is `(Int, Int) -> Int`). - `ailang-core::desugar::tests`: 9 → 10 (`let_rec_capture_match_arm_is_deferred_to_post_typecheck`: asserts no fn was lifted at desugar time and a Term::LetRec still survives in the outer body for the post-typecheck pass). **Cumulative state, post-16b.4.** - Stdlib unchanged (5 modules, 29 combinators). - `Term` enum: 11 variants (unchanged). All pre-16b.4 fixture hashes bit-identical (verified via `ail manifest` on the four prior LetRec/lit_pat fixtures: hashes match pre-16b.4 values). - Compiler stages: load → desugar → typecheck → lift_letrecs → codegen (unchanged from 16b.3). - The `unreachable!` arms in `ailang-codegen` and the typing rule in `ailang-check` are unchanged. - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged). **Queue update post-16b.4.** 16b.4 done. Open: **16b.5** (closure conversion — lifts the "name-as-value-only" restriction for both LetRec and Lam; needs ABI-level work, out of scope for the 16b.x sweep that's been about scope-of-capture restrictions only), **16b.6** (LetRec inside a `Type::Forall`-quantified fn — needs a synthesised `Forall` for the lifted signature plus rigid-var threading; the `lift_letrecs` panic at the Forall-LetRec arm is the entry point), **16b.7** (nested LetRec mutual capture — generalised lifting that accumulates captures across nesting; the post-order traversal in `lift_letrecs` already handles the strictly-nested non-mutual case but the mutual case needs joint lifting). 16d (chain-machinery exhaustiveness or `__unreachable__`), 16e (`==` extension to Bool/Str/Unit), 17a (per-fn arena, gated) unchanged. ## Iter 16b.5 — LetRec name as value (in_term only) via eta-Lam wrapper **Goal.** Lift the "callee-only use of the LetRec name" restriction for the **in-clause** of a `(let-rec ...)`. After this iter, the LetRec name `f` may appear in `in_term` as a value (e.g. passed to a higher-order combinator, bound to another `let`, stored in an ADT field). References to `f` as a value INSIDE the LetRec's own body remain rejected — that case has a chicken-and-egg with the call-site rewrite (the body's bare `f` references would need to call a name that doesn't exist before the lift) and is queued as a non-trivial extension. **Architectural choice (no new ABI).** Reuse the closure-pair machinery from Iter 8b (`lower_lambda` in `ailang-codegen`). After the existing 16b.2/16b.3 capture rewrite produces the lifted fn `f$lr_N(p1..pk, c1..cm) -> rt` and rewrites every `(app f a..)` → `(app f$lr_N a.. c1..cm)`, the lifter detects whether `in_term` contains any non-callee use of `f`. If yes, it WRAPS the rewritten in-term in ``` (let f (lam (params p1..pk) (ret rt) (effects E) (app f$lr_N p1..pk c1..cm)) ) ``` The Lam's free variables (the captures `c1..cm`) are picked up automatically by the existing 8b capture analysis in `lower_lambda`. The Lam's signature mirrors the LetRec's declared type, so any non-callee `Var{f}` in `in_term` resolves to a `Fn(t1..tk) -> rt ![E]` value — usable wherever the typechecker expects that type. Callee-position references in `in_term` are already rewritten to `f$lr_N` (no closure indirection at the call site; the wrap only affects value-position uses). The reduction is satisfying: 16b.5 reduces a problem that looked like it needed a new ABI to a problem already solved by Iter 8b. No closure ABI changes, no codegen plumbing, no LLVM IR change. **Where the wrap happens.** Two sites — both fast paths can encounter LetRecs with non-callee uses in in_term: 1. **`crates/ailang-core/src/desugar.rs`** (~+50 LOC inside the existing `Term::LetRec` arm). The body-side `find_non_callee_use` panic was kept (now points at the body-only restriction rather than the generic 16b.5 deferral). The in_term-side check no longer panics; instead it sets a flag `in_has_value_use`. After the standard call-site rewrite, when `in_has_value_use` is true, the in-term is wrapped in a `Term::Let { name: f, value: Term::Lam, body: }`. The Lam's `effects` come from `peel_forall_to_fn(ty)`. The defensive `subst_var` on `in_term` is dropped — bare `Var{f}` references must reach the new let-binding unchanged. 2. **`crates/ailang-check/src/lift.rs`** (~+50 LOC inside the existing post-typecheck LetRec arm). Symmetric to (1). The wrap is identical in shape. The lift's effects come straight from `ty` (already known to be `Type::Fn` at this point — Forall is rejected upstream). **What stays rejected.** - `find_non_callee_use(body, name)` returns Some → panic with the 16b.5 body-only message: "name-as-value of a LetRec inside its own body is not yet supported." Both sites. - `EnclosingLetRec` capture (16b.7) — unchanged. - Forall-typed LetRec (16b.6) — unchanged. **Did the codegen Lam machinery handle the wrapped form on the first try?** Yes. The eta-Lam is a perfectly ordinary `Term::Lam` whose body happens to be a single `Term::App` to a top-level synthetic fn with the lam's params + free-var captures appended. 8b's `collect_captures` walks the Lam body, sees `f$lr_N` in top-level fns (it's been pushed to `module.defs` already), sees `p1..pk` in `bound`, and classifies the remaining `c1..cm` as captures — exactly right. No AST massaging was needed. The non-capture variant (no extras) reduces to the simplest possible Lam (params-only body), which 8b also handles trivially. End-to-end ran on first attempt for both fixtures. **What shipped.** - `crates/ailang-core/src/desugar.rs`: split `find_non_callee_use` into body-only (panic) and in_term-only (sets a flag, then wraps below). Drop the post-rewrite `subst_var` on the in-term — it would otherwise rename the very `Var{f}` references that need to resolve to the eta-Lam binding. Updated docstring on the pre-existing panic test (`let_rec_name_as_value_panics` → `let_rec_name_as_value_in_body_panics`) and added a new positive unit test `let_rec_name_as_value_in_in_term_wraps_to_eta_lam` that constructs an in_term-side value use and asserts the resulting AST shape (lifted FnDef appended; main's body is `Let { f, Lam{x -> App f$lr_0 x}, }`). - `crates/ailang-check/src/lift.rs`: same split. Drop the defensive `subst_var` on `in_term` when there's a value use; keep it as no-op when there isn't (zero-capture symmetry). - `examples/local_rec_as_value.ailx` + `.ail.json`: factorial bound by `(let-rec ...)` inside main, then in the in-clause passed as a VALUE to a higher-order combinator `apply5 : (Fn(Int) -> Int) -> Int`. No captures. Expected stdout: `120` (= apply5(factorial) = factorial(5)). - `examples/local_rec_as_value_capture.ailx` + `.ail.json`: variant with capture. `run_with_base(base)` builds a recursive `factorial_plus` that uses `base` in its base case, then passes the helper as a VALUE to `apply5`. The lifted fn is `factorial_plus$lr_0(n: Int, base: Int) -> Int`; the eta-Lam binds `factorial_plus` to a `(lam (n) (app factorial_plus$lr_0 n base))` whose free var `base` becomes a standard 8b closure-env capture. Expected stdout: `1320` (= 5*4*3*2*(1+10)) and `12120` (= 5*4*3*2*(1+100)). - `crates/ail/tests/e2e.rs`: `local_rec_as_value_demo` and `local_rec_as_value_capture_demo` (e2e count 41 → 43). - `crates/ailang-core/src/desugar.rs::tests`: `let_rec_name_as_value_in_body_panics` (renamed from the 16b.2-era panic test; same fixture) and the new positive test `let_rec_name_as_value_in_in_term_wraps_to_eta_lam` (desugar count 10 → 11; net +1). **Cross-iter regression check.** All five existing LetRec/lit_pat fixtures (`local_rec_demo`, `local_rec_capture`, `local_rec_let_capture`, `local_rec_match_capture`, `lit_pat`) build, run, and produce identical stdout. Their on-disk hashes (reported via `ail manifest`) are unchanged because the desugar pass runs after `load_module` and never touches canonical bytes. **Tests: 113 → 116 (+3).** - e2e: 41 → 43 (`local_rec_as_value_demo`, `local_rec_as_value_capture_demo`). - `ailang-core::desugar::tests`: 10 → 11 (one rename + one new positive test). Net +1. **Cumulative state, post-16b.5.** - Stdlib unchanged (5 modules, 29 combinators). - `Term` enum: 11 variants (unchanged). All pre-16b.5 fixture hashes bit-identical. - Compiler stages: load → desugar → typecheck → lift_letrecs → codegen (unchanged from 16b.3). - The four `unreachable!("Term::LetRec eliminated by desugar")` arms in `ailang-codegen` are still correct — by the time codegen runs, no LetRec survives. The eta-Lam is a `Term::Lam`, which has its own well-trodden codegen path. - DESIGN.md unchanged. Pipeline is unchanged. - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged). **Queue update post-16b.5.** 16b.5 done for the in_term-only slice. Open: **16b.5-body** (informal — not formally numbered; "non-trivial extension" per the orchestrator's brief), where the LetRec name is used as a value INSIDE its own body. Solving that needs either eta-conversion-of-self (the body's `f` would resolve to a fresh Lam built around the lifted callee, but constructing it inside the body before the lift completes is order-sensitive) or a true closure conversion that doesn't lift to a top-level fn at all. **16b.6** (Forall-typed LetRec), **16b.7** (nested mutual LetRec capture), 16d, 16e, 17a unchanged. ## Iter 16b.6 — LetRec inside polymorphic enclosing fn **Goal.** Lift the "monomorphic enclosing fn only" restriction that 16b.2 introduced. A `(let-rec ...)` inside a fn whose declared type is `Forall(α1..αn, Fn(...))` is now supported, provided (a) the LetRec name is callee-only (any non-callee use in `body` was already rejected by 16b.5; non-callee use in `in_term` is rejected for the polymorphic case here — see "closure-poly" below), and (b) other 16b.x restrictions still apply (no nested LetRec mutual capture → 16b.7). **Architectural choice.** The lifted top-level fn is built as `Forall(α1..αn, Fn(t1..tk, T1..Tm) → tr)` where `α1..αn` are exactly the enclosing fn's type vars. Capture types `T1..Tm` may mention any of those vars; the outer `Forall` binds them back. Inside the enclosing fn's body every call site of the lifted fn is monomorphic relative to the enclosing fn's current instantiation, so codegen's existing Iter-12b/14a monomorphisation machinery picks up `f$lr_N` from the `mono_queue`, substitutes type args into both the original params and the appended capture types (a single `apply_subst_to_type` traversal handles both — captures live in the same `Type::Fn.params` list as originals), and emits specialised `f$lr_N__I`, `f$lr_N__B`, … per instantiation. **No codegen changes were needed**: the lifted fn becomes an ordinary `Forall` top-level fn and flows through the same pipeline as a user-written polymorphic def. **Scope-building change.** 16b.2 entered fn-params of a `Forall`-typed enclosing fn as `ScopeEntry::LetBound` — defensive, because the param types could mention outer type vars and the lift had no way to bind them. With the `Forall(vars, Fn(t1..tk, T1..Tm) → tr)` synthesis above, that bind site now exists. Fn-params of a polymorphic enclosing fn therefore enter the scope as `ScopeEntry::KnownType(t)` (matching the monomorphic case). The `LetBound` fallback is reserved for `Term::Let`-bound names and for malformed fn types (arity mismatch, non-Fn). Both `Desugarer` and `Lifter` gained a `current_def_forall_vars: Vec` field that's populated on entry to each `Def::Fn` and read by the LetRec arm to decide whether to wrap the augmented type in a `Forall`. **Why name-as-value-in-in-term is excluded for the polymorphic case.** The 16b.5 wrap synthesises a `Term::Lam` whose body calls the lifted fn positionally. `Term::Lam` has no `Forall`-quantification slot — wrapping a polymorphic lifted fn into a monomorphic Lam would lose the type vars. The proper fix is closure conversion with polymorphism (a closure pair generic over `α1..αn`); that's a separate, harder iter. Both desugar and lift now panic with a clear message referencing queue tag `closure-poly` (informally `16b.5b`) when this combination is detected. Name-as-value-in-in-term in a MONOMORPHIC enclosing fn continues to work via the 16b.5 eta-Lam wrap. **What shipped.** - `crates/ailang-core/src/desugar.rs` (1931 → 1939 LOC + tests; +50/+135 net of test additions). - New `Desugarer.current_def_forall_vars` field. Populated on entry to each `Def::Fn` (cloned from `f.ty`'s `Forall.vars`, or empty for monomorphic), restored on exit. - `desugar_module`'s per-def loop unified: instead of branching `Type::Fn` vs `Type::Forall`, it peels the inner `Type::Fn` once and treats both shapes uniformly for fn-param scope-building. The defensive `LetBound` fallback now fires only on a malformed fn type (arity mismatch or non-Fn). - `Term::LetRec` arm: when `!current_def_forall_vars.is_empty() && in_has_value_use`, panic with the new `Iter 16b.6` / `closure-poly` message. When the fast-path lift fires, the `augmented_ty` is `Type::Forall { vars: current_def_forall_vars.clone(), body: Box::new(Type::Fn { ... }) }` if `vars` is non- empty, else the bare `Type::Fn` as before. - `crates/ailang-check/src/lift.rs` (757 → 791 LOC; +34). Symmetric changes to `Lifter`: new `current_def_forall_vars` field, populated alongside the existing `rigid_vars` install in the per-def loop; same Forall-wrap on `augmented_ty`; same `closure-poly` panic for the polymorphic + name-as-value-in-in-term combination. The lift's `synth_type` and `type_check_pattern_for_lift` were unchanged — capture types containing type vars flow through verbatim because the typechecker installs the same rigid vars on entry. - `examples/poly_rec_capture.ailx` + `.ail.json` (49 LOC source). `apply_n_times : Forall(a). Fn(Int, a, Fn(a) -> a) -> a` applies `f` to `x` exactly `n` times. The recursive helper `loop` captures `f` from the enclosing fn's params. `f`'s type is `Fn(a) -> a`, mentioning `a`. The lift produces `loop$lr_0 : Forall(a). Fn(Int, a, Fn(a) -> a) -> a`. `main` drives `apply_n_times` at TWO instantiations (`a = Int` with `succ` to compute `succ` applied 5x to 0 → 5; `a = Bool` with `flip` (a top-level wrapper around `not`, since `not` is a builtin without a value-position adapter) to compute `flip` applied 4x to false → false). Codegen specialises `loop$lr_0` twice, emitting `loop$lr_0__I` and `loop$lr_0__B` — confirmed in the generated IR. The `flip` indirection is incidental (it works around an unrelated builtin-as-value gap, not a 16b.6 limitation). - `crates/ail/tests/e2e.rs::poly_rec_capture_demo` (+25): e2e count 43 → 44. - `crates/ailang-core/src/desugar.rs::tests` (+2): `let_rec_capture_under_polymorphic_enclosing_fn_lifts_to_forall_fn` (positive — asserts the lifted FnDef's type is `Forall(a, Fn(Int, a) -> a)` with the `a`-typed capture appended), and `let_rec_name_as_value_in_polymorphic_enclosing_fn_panics` (`#[should_panic(expected = "16b.6")]`). - `crates/ailang-check/src/lib.rs::tests` (+1): `lift_letrecs_under_polymorphic_enclosing_fn_produces_forall_fn` exercises the post-typecheck lift path (a `Term::Let`- bound capture forces deferral to `lift_letrecs`); asserts the lifted ty is `Forall(a, Fn(Int, a, Int) -> a)`. **Codegen integration: did anything need to change?** No. Verified end-to-end: the lifted `Forall` fn is registered in `module_polymorphic_fns` by `lower_workspace`'s pass-1 (no distinction between user-written and synthetic Forall fns — both are `FnDef`s with `Type::Forall` types). At each call site inside the enclosing fn, `lower_polymorphic_call` derives the substitution from the actual arg types, specialises through `apply_subst_to_type` (which substitutes through `Type::Fn.params` uniformly — original params and appended captures share the same list), and queues a specialisation. `emit_specialised_fn` consumes the queue, substitutes type vars in both the body and the type, and emits the LLVM fn. `poly_rec_capture` produces `loop$lr_0__I` and `loop$lr_0__B` in the IR, both correctly typed (capture `f: ptr` carries through unchanged because `Fn(_) -> _` is `ptr` at the LLVM level). **Cross-iter regression check.** All seven prior fixtures (`local_rec_demo`, `lit_pat`, `local_rec_capture`, `local_rec_let_capture`, `local_rec_match_capture`, `local_rec_as_value`, `local_rec_as_value_capture`) build, run, and produce identical stdout. Their on-disk hashes (`ail manifest`) are bit-identical: hashes flow from canonical JSON, untouched by 16b.6's desugar/lift changes. **Tests: 116 → 120 (+4).** - e2e: 43 → 44 (`poly_rec_capture_demo`). - `ailang-check::tests`: 28 → 29 (`lift_letrecs_under_polymorphic_enclosing_fn_produces_forall_fn`). - `ailang-core::desugar::tests`: 21 → 23 (positive lift + negative panic). Net +2. **Cumulative state, post-16b.6.** - Stdlib unchanged (5 modules, 29 combinators). - `Term` enum: 11 variants (unchanged). All pre-16b.6 fixture hashes bit-identical. - Compiler stages: load → desugar → typecheck → lift_letrecs → codegen (unchanged pipeline; only the per-stage logic at the LetRec arm changed). - The four `unreachable!("Term::LetRec eliminated by desugar")` arms in `ailang-codegen` remain correct: every surviving LetRec is still gone before codegen runs (the poly fast-path lifts here in desugar; the deferred path is handled by `lift_letrecs`). The panic message in codegen could be updated post-16b.3 to "by desugar OR lift_letrecs"; left as-is for now (the invariant holds either way). - Compiler bugs surfaced and fixed in dogfood since 14a: 5/5 (unchanged). **Queue update post-16b.6.** 16b.6 done. Open: **`closure-poly`** (informally 16b.5b — name-as-value of a LetRec inside a polymorphic enclosing fn; needs closure pair generic over the enclosing fn's type vars). **16b.5-body** (name-as-value INSIDE the LetRec's own body). **16b.7** (nested LetRec mutual capture). 16d (chain-machinery exhaustiveness or `__unreachable__`), 16e (`==` extension to Bool/Str/Unit), 17a (per-fn arena, gated) unchanged.