DESIGN.md: - Term schema: add `ctor`, `match`, `lam` rows. The schema fragment is now exhaustive for the supported language; prior versions silently dropped Iter 3/Iter 8 additions. - "What is not (yet) supported": closures-with-capture moves out of pending. New positive bullet for anonymous lambdas. Polymorphism added as an explicit pending bullet (Forall is parseable but not inferred). Smoke-test list extended with closure.ail.json. JOURNAL: append Iter 8 entry covering both 8a (closure-pair ABI) and 8b (Term::Lam + capture). Includes the rationale for skipping TIR (KISS won — `synth` already provides enough type info), the closure- conversion sketch, hash stability check (existing examples produce the same fn hashes pre vs post Iter 8), and the Iter 9 plan with two candidates ranked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 KiB
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,clangavailable. - Decided against
inkwellin favour of LLVM IR text emit. Rationale in DESIGN.md. - Workspace layout:
crates/ailang-core— AST, type, hash, JSON schemacrates/ailang-check— typechecker (comes later)crates/ailang-codegen— lowering + LLVM IR emitcrates/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:
current_block_label_for_phiis a heuristic (see codegen). On nestedifterms 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.- 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).
- The
hashfield 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):
- Clean up block-label tracking, with a nested-if test.
- Strings as a literal +
io/print_str. - Hello-world example as a second E2E test.
- CLI:
--jsonoutput 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
ifs work). Testmax3_picks_largestprotects it. - Strings as
Lit::Str { value }, typeStr-> LLVMptr, withio/print_streffect op.examples/hello.ail.jsonprints a string. - CLI:
manifest --json,builtins --jsonfor tool consumers. ail deps [--of NAME] [--json]lists call edges. Effect ops are taggedeffect:NAMEso 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
nameandfields: [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 beVarorWild. - 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:
- 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.
- 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.
- No runtime pretty-printer for ADT values.
io/print_intis enough for demos, but a genericshow :: a -> Strfor 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:
- 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.
- Structured error output (
ail check --json). So tools can react to type errors without parsing text. - 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/<name>.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:
- Structured error output (
ail check --jsonwith a Diagnostic struct, stable codes likeunbound-var,type-mismatch). ail diff <a> <b>— semantic module diff via per-def hash comparison.- 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:
93fe723Iter 4a:ail check --jsonwith aDiagnosticstruct (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<Diagnostic>.c652b12Iter 4b:ail diff <a> <b> [--json]as a structural top-level def diff via BLAKE3 hash. Four categories (added/removed/changed/ unchanged), sorted alphabetically, exit code 1 on diff.74a2005Iter 4c: IR snapshot tests incrates/ail/tests/snapshots/{sum,max3,hello,list}.ll. Normalisation oftarget triple. Update viaUPDATE_SNAPSHOTS=1 cargo test ir_snapshot_. Mismatch produces an.actualfile.
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: Stringtrack is now additionally protected against regression by Iter 4c snapshot tests. Debt closed.
New / sharpened debt:
check_moduleis 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.source_filenamein the IR is hard-coded to"<module>.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<String, Module> }plusload_workspace(entry: &Path), which followsimportsrecursively from the entry module. Convention:import { module: "foo" }resolves to<dir>/foo.ail.jsonnext to the entry. Cycle detection. CLI: existing subcommands keep working on a single module; a newail workspace <entry>lists all reachable modules with hash. Tests: two small example modules with an import relation; cycle test. - 5b — cross-module typecheck. The typechecker takes
&Workspaceinstead of&Module. Imports are mounted in the env as a namespace (alias.defor, 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_<module>_<def>. 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,diffgain a--workspacemode (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<Diagnostic>as the top-level API.check_moduleis 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>.<def>. Prefix is an import alias or module name. No new AST node, no renamed fields ⇒ hashes stay stable; allir_snapshot_*still green. - Three new diagnostic codes:
unknown-module,unknown-import,invalid-def-name(withctx.reason: "contains-dot"). - CLI:
ail check <entry>now always loads viaload_workspace. Workspace load failures become structured diagnostics in JSON mode with codesmodule-not-found,module-cycle,module-name-mismatch,module-hash-mismatch,schema-mismatch.ail buildandail emit-irstay per single module (cross-module codegen is 5c). - Examples:
ws_main.ail.jsonnow callsws_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 testcheck_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 asunbound-var.
2026-05-07 — Iter 5c done: cross-module codegen
- Mangling break (deliberate). All AILang functions are now called
@ail_<module>_<def>, even in single-module programs. The old form@ail_<def>is gone. Strings/const globals analogously (@.str_<module>_<hint>_<idx>,@ail_<module>_<const>). The entry point staysmainas C ABI: adefine i32 @main()trampoline calls@ail_<entry-module>_main(). If the entry module has nomain : () -> Unit !IO, the build fails withMissingEntryMain. - Workspace lowering. New top-level API
ailang_codegen::lower_workspace(ws: &Workspace) -> Result<String>produces a single.llfor 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 handlesType, codegen handlesFnSigfrom llvm types). - CLI.
ail buildandail emit-irnow 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.llshow the new mangling. Newws_main.llsnapshot documents the cross-module build:@ail_ws_main_maincalls@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_filenamehardening). In the workspace world,source_filenameis now uniformly<entry-module>.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 <entry> --workspacenow 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 notationws_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_listsis 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:
- Mangling consistency holds.
@ail_<module>_<def>is consistent across functions, constants, string globals, and cross-module calls. The trampoline is correct. ADT constructors are deliberately symbol-free (inline malloc). - Module hashes bit-identical since Iter 4. The Iter 5c snapshot regeneration was a codegen-output change, not a hash break.
- Drift, due now:
- DESIGN.md says
define i64 @main(), codegen emitsdefine i32 @main()(seesum.ll:35). - String-schema notation in DESIGN.md was shortened
(
@.str_<module>_<idx>instead of@.str_<module>_<hint>_<idx>).
- DESIGN.md says
- Debt that accrues interest: the
depsbuiltin leak (Task #22) has become a falsehood in workspace mode — close it before the next big jump.
Plan iteration 6 — clean-up:
- Fix DESIGN.md drift. Update the mangling-scheme block, correct the
@mainsignature, and note the string globals precisely. depshardening (#22). Build a top-level def table per workspace; filter edges whose target is not a top-level symbol, or emit them as separatebuiltin:/local:categories. Function parameters via lexical scope tracking from walk_term.- Multi-diagnostic refactor (#20).
check_workspaceaccumulatesVec<Diagnostic>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) fromlist(), so the install-list and the deps-filter share one source of truth. walk_termincrates/ail/src/main.rsnow threads ascopeset: fn-params seed it;Letadds the bound name for the body only;Matcharms add their pattern variables (bind_patternhelper, MVP rule "ctor sub-patterns are Var/Wild") and roll them back after. Var refs that hitscopeorbuiltinsare 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<CheckError> 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 --jsonis 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_<m>_<def>, typeptr). - Fn-typed parameters can be called as
f(args)— the body emits an indirectcall <ret> (<param-tys>) %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— seeexamples/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:
llvm_typelearnedType::Fn { .. } -> "ptr". The actual signature travels separately. New helperfn_sig_from_typelifts an AILang fn-type into anFnSig(LLVM types only).Emittergot a sidetable:ssa_fn_sigs: BTreeMap<String, FnSig>, keyed by SSA value (or@global). It's reset per function body.- At
emit_fnentry, every fn-typed parameter registers(%arg_<name>, sig)in the sidetable. 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.lower_term(Term::App)dispatches:- if callee is a
VarAND not shadowed AND statically known (is_static_calleecovers builtin operators, qualifiedprefix.def, current-module fns), keep the existing directlower_apppath — no extra indirection in the IR; - otherwise lower the callee, expect type
ptr, look up the sig in the sidetable, emitemit_indirect_call.
- if callee is a
Term::Ifpropagates 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 theapply-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_incbuilds and runsexamples/hof.ail.json, asserts the binary prints42.- 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/manifestalready render fn-typed params correctly via the existingpretty::type_to_string(((Int) -> Int, Int) -> Int). No tooling change required. - KISS: every alternative I considered (full
LocalTypeenum, 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:
define <ret> @ail_<m>_<f>_adapter(ptr %_env, <params>) {
%r = call <ret> @ail_<m>_<f>(<args>)
ret <ret> %r
}
@ail_<m>_<f>_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:
{ "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:
-
Free-variable analysis.
collect_captureswalks the body skipping builtins (+,==, ...), the current module's top- level fns, and qualifiedprefix.defnames. The remainder are captures. Inner lambdas contribute their own free vars upward. -
Lift to thunk. For each lambda, generate a fresh
@ail_<m>_<def>_lam<id>(ptr %env, params...). State the body into a side buffer (the emitter'sbody/locals/counterare 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 adeferred_thunksqueue and is appended after the parent fn's}— LLVM IR doesn't care about fn order. -
Pack at the use site. In the OUTER body emit:
%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%closis the value returned by the Lam term. Its sig is registered in the sidetable so subsequent indirect calls work. -
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/filterover 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.jsonproduced the same fn hashes (db33f57cb329935e,d9a916a0ed10a3d3) before and after Iter 8. Existing modules withoutTerm::Lamserialise bit-identically. ✓ - Consistency: DESIGN.md "What is not (yet) supported" rewritten
in the same edit. The Term schema gained
lam,ctor,matchrows that were already supported but had been omitted from the schema fragment. Now the doc is exhaustive for the supported language. - Visualisation:
ail describealready renders Lam terms (added pretty-printer rule), and the codegen IR forclosure.ail.jsonreads 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:
-
Polymorphic inference. Make
Type::Forallactually work insynth— instantiate fresh type variables at each use site, allowlet id = \\x. x in (id 1, id true). This unblocks genericmap/fold/etc. without per-type clones. Probably small (~150 LOC in the typechecker; codegen already monomorphises by instantiation when it lowers the call). -
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.