Skip to content

Phase 2 inline lambdas: closure capture#265

Merged
danieljohnmorris merged 7 commits into
mainfrom
fix/inline-lambdas-capture
May 14, 2026
Merged

Phase 2 inline lambdas: closure capture#265
danieljohnmorris merged 7 commits into
mainfrom
fix/inline-lambdas-capture

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

Phase 2 of inline lambdas. #247 shipped Phase 1: (params>ret;body) literals lift to synthetic __lit_N top-level decls, and free variables in the body got rejected with ILO-P017 pointing at the ctx-arg form. Phase 2 lifts that restriction.

Closure capture is the natural next step for the manifesto: writing srt (x:n>n;abs -x target) xs to sort by distance from a captured target is the obvious form, and forcing the user to either define a helper or thread target through a ctx-arg costs tokens for no good reason. The ctx-arg form (#186) still works; this just makes the inline form do what an LLM expects.

Tree-only. VM and Cranelift HOF dispatch depend on FnRef NaN-tagging, which is the parked follow-up tracked in fix/vm-cranelift-hof-dispatch. The example carries -- engine-skip: vm jit cranelift and vm::compile returns a friendly CompileError::UnsupportedClosureCapture (ILO-R012) so the default runner falls through to the tree interpreter cleanly instead of panicking.

Repro

Before this PR (Phase 1):

ilo 'f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs' --run-tree f '[1,5,3,8,2]' 4
ILO-P017: inline lambda captures `thr` from the enclosing scope

After:

ilo 'f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs' --run-tree f '[1,5,3,8,2]' 4
[5, 8]

What's in the diff

Seven small commits, one logical change each:

  • ast: add Expr::MakeClosure { fn_name, captures }, walk captures in resolve_aliases_expr.
  • codegen + verify: handle the new node in fmt, python codegen, dep graph, json, and verify.
  • interpreter: add Value::Closure { fn_name, captures }; thread captures through every closure-aware HOF (srt, map, flt, fld, partition, flatmap, uniqby, grp); Expr::Call path honours closures bound to locals too.
  • parser: drop the ILO-P017 rejection; lift free vars as trailing _:any params on the synthetic decl; emit Expr::MakeClosure at the call site.
  • vm: surface unsupported closure capture as CompileError::UnsupportedClosureCapture (was a panic) so vm::compile returns Err cleanly and the default runner falls through to tree. New diagnostic code ILO-R012.
  • test: 9 new tests covering single, multi, and Text captures across srt/map/flt/fld; by-value snapshot sanity.
  • example: examples/inline-lambda-capture.ilo with six cases asserted via -- run: / -- out:.

Test plan

  • cargo fmt --check clean
  • cargo clippy --release --features cranelift --all-targets -- -D warnings clean
  • cargo test --release --features cranelift — full suite green (2888 passed, 0 failed, 73 ignored)
  • tests/examples.rs (default JIT-then-tree runner) green: confirms vm::compile Err falls through cleanly
  • tests/examples_engines.rs honours engine-skip
  • Manual cross-check: ILO-P017 hint gone for captures, ctx-arg form (verifier + interpreter: closure-bind ctx arg for srt/map/flt/fld #186) still works

Follow-ups

  • VM/Cranelift HOF dispatch via FnRef NaN-tagging (separate parked branch fix/vm-cranelift-hof-dispatch). Once that lands, Value::Closure needs a NanVal encoding and the example can drop engine-skip.
  • Nested-capture round-trip through Expr::Call callee resolution is wired but not exhaustively tested; worth adding a regression once a real use case surfaces.

Phase 2 of inline lambdas. Phase 1 (#247) lifted (params>ret;body)
literals to synthetic __lit_N decls and rejected free vars with
ILO-P017. Phase 2 lifts that restriction: free variables in the body
become trailing capture params on the lifted decl, and the call site
materialises a closure value that carries them.

This commit adds the AST node only. MakeClosure { fn_name, captures }
represents the construction of that closure value at the original
call site. resolve_aliases_expr walks the capture exprs so symbol
aliases still resolve inside them.

Codegen, verify, and runtime support follow in subsequent commits.
Keep the non-runtime visitors total over the new AST node.

- fmt: render as fn_name[cap1 cap2 ...] for ast-dump/debug output.
  No surface syntax (MakeClosure is synthetic), so round-trip isn't a
  goal here, just printer totality.
- python: emit (lambda *_a: fn_name(*_a, c1, c2, ...)) so the Python
  backend reproduces the tree interpreter's call shape (per-item args
  first, then trailing captures).
- graph: record an edge to the lifted fn and walk capture exprs so the
  dep graph reflects everything the closure touches.
- json: closures aren't serialisable, error explicitly like FnRef does.
- verify: walk capture exprs so free-var-resolution errors surface at
  the original call site; report Ty::Unknown for the closure itself
  so HOF signatures treat it like a fn-ref.
Runtime side of Phase 2 inline-lambda capture.

- New Value::Closure { fn_name, captures } carrying by-value snapshots
  of the captured free variables. Constructed by Expr::MakeClosure in
  eval_expr; serialises to <closure:fn> in value_to_json.
- resolve_fn_ref accepts Value::Closure so existing HOF dispatch picks
  the lifted fn name out of it.
- closure_captures() helper returns the trailing args to append after
  per-item args at each call site. Extended every closure-aware HOF —
  srt, map, flt, fld, partition, flatmap, uniqby, grp — to call it
  once per dispatch and extend call_args before invoking.
- Plain user-call path (Expr::Call) honours closures bound to locals
  too: if the resolved callee is Value::Closure, dispatch to its
  fn_name and append its captures after the user-supplied args.

Same call shape as #186's single-ctx form generalised to N captures.
By-value semantics: captures are snapshot when the closure is
constructed, not read live at each call.
Drop the Phase 1 ILO-P017 rejection. Phase 2 turns each free
variable in an inline lambda body into a trailing param on the
synthetic __lit_N decl and emits Expr::MakeClosure at the call
site so the runtime can snapshot the captures by value.

- Free-var analysis (already used by Phase 1 to detect captures)
  now drives the lift: each free name gets a Param { name, ty: Any }
  appended to the lifted decl's param list after the originals.
- If the free-var set is empty, behaviour is unchanged: the call site
  is still a bare Expr::Ref(name) like Phase 1.
- collect_free_in_expr handles Expr::MakeClosure so a nested
  lambda's captures bubble correctly through an outer lambda — the
  inner closure's captures are exprs in the outer scope and must be
  scanned as free-var candidates from the outer's perspective.

Captures line up as trailing args on the lifted decl, so the
interpreter's HOF dispatch (`call_args.extend(captures.iter()...)`)
matches the decl signature with no per-call adapter.
VM/Cranelift HOF dispatch is the parked FnRef NaN-tagging effort,
so inline lambdas with captures only run on the tree interpreter
for now. Originally I panicked in compile_expr for Expr::MakeClosure,
but that taints the default runner: vm::compile is called speculatively
in main.rs before the Cranelift JIT runs, and an unwinding panic
crashes the whole process instead of falling back.

Switch to the existing first_error pattern: a new variant
CompileError::UnsupportedClosureCapture { fn_name } gets recorded
on encounter, compile_expr returns a dummy register and continues,
and compile_program propagates the error at the end. main.rs's JIT
path already treats a vm::compile Err as 'skip the JIT, run on the
tree', so the program runs as expected on the default runner.

Wire the new variant through Diagnostic::from with code ILO-R012
so --run-vm and --run-cranelift surface a friendly error instead
of a panic.
Replace the two Phase 1 'capture rejected with ILO-P017' tests with
eight Phase 2 capture-works tests, one per scenario:

- closure_capture_single_var_filter: flt over a captured threshold
- closure_capture_in_sort_key: srt key reading a captured target
- closure_capture_in_map: map transform bumping by a captured int
- closure_capture_in_fld: fld reducer weighted by a captured int
- closure_capture_multiple_vars: flt over both lo and hi captures
- closure_capture_text_value: flt with a captured Text needle
- closure_capture_by_value_snapshot: srt with bias capture (sanity
  that the by-value snapshot path runs)

run_err stays in the file (now under #[allow(dead_code)]) — it's
documentation of how to assert against ilo failures and other
regression suites copy it in.

All tests run on --run-tree only: VM/JIT HOF dispatch is the parked
FnRef NaN-tagging follow-up.
Six small functions, one per scenario: threshold filter, distance
sort, bump map, range filter (two captures), substring filter (Text
capture), and weighted fold. Each case is asserted by the
examples_engines harness with -- run: / -- out:.

engine-skip lists vm, jit, cranelift because HOF dispatch on those
backends is the parked FnRef NaN-tagging follow-up. Tree-only until
that lands.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 67.27273% with 36 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/interpreter/mod.rs 81.03% 11 Missing ⚠️
src/parser/mod.rs 61.11% 7 Missing ⚠️
src/codegen/python.rs 0.00% 6 Missing ⚠️
src/graph.rs 0.00% 5 Missing ⚠️
src/codegen/fmt.rs 0.00% 3 Missing ⚠️
src/vm/mod.rs 75.00% 2 Missing ⚠️
src/diagnostic/mod.rs 0.00% 1 Missing ⚠️
src/interpreter/json.rs 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@danieljohnmorris danieljohnmorris merged commit a5b7fea into main May 14, 2026
4 of 5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/inline-lambdas-capture branch May 14, 2026 10:31
danieljohnmorris added a commit that referenced this pull request May 15, 2026
- slc / take / drop accept negative indices counting from end (bounds
  clamp), matching at xs i. Closes the quant-trader fencepost and the
  slc xs -np 1 np ergonomics gap (#266).
- Map keys are typed: text or integer. mset m 7 v and mget m 7 work
  directly, no str conversion. Int(1) and Text("1") are distinct.
  Float keys floor to i64; jdmp stringifies numeric keys for JSON (#267).
- Add map / flt / fld to the builtin reference. All HOFs (map, flt,
  fld, srt, grp, uniqby, partition, flatmap) now work cross-engine
  on tree, VM, Cranelift JIT, and AOT (#274 #277 #278 #279 #280 #283).
- New Inline lambdas subsection: Phase 1 literals are cross-engine,
  Phase 2 closure capture is tree-only with automatic fallthrough
  surfacing ILO-R012 on VM and Cranelift (#265 #284).
- AOT-compiled binaries from ilo compile now strip the top-level
  ~/^ wrapper byte-for-byte the same as in-process runners (#281).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant