A closure keeps only what it mentions
Amended 2026-10-19 by await-is-the-block-run-here:
serviceis deleted, so the workers that start from their creator’s session arespawn,watch,par/allchildren and a pipeline stage.
A thunk value is ⟨M, ρ|occ(M)⟩: its computation over just the session
bindings its body mentions. The law holds by construction: Closure’s
fields are private and Closure::new is its one constructor, which narrows
the environment it is handed (Env::restrict) to the names
ir::referenced_names finds in M — the lease harvest’s walk, exhaustive
and binder-blind. By the standard lemma ⟦M⟧ρ = ⟦M⟧(ρ|occ(M)) nothing
observable changes; what changes is residency: a closure formed after
let big = … keeps big alive only if it mentions it.
What was decided
- occ, not FV. The walk collects every name
Mmentions, bound or free: every variable, every bare command head, every member of aRecgroup. The surplus over FV(M) — a sessionxthatMrebinds before reading, a binding named like a^namehead, a sibling’s session namesake — is kept and never read: it costs bytes, never meaning. Only the session tier is narrowed; the natives and prelude tiers stay shared whole. - Narrowing that drops nothing is the identity, root included. The wire
decode, the fork’s scrub and
Rec’s siblings are each handed an environment already scrubbed for the same body, so each keeps the root the wire and the scrub intern by; every empty capture is the one empty map. - The machine’s ⟨M, ρ⟩ are computation closures, a type apart.
Focus::Eval { comp, env }andTerminal::Lambda { comp, env }hold the whole environmentMwas reached in. They are transient — never stored, sent, or kept alive by a binding — and stay unscrubbed: scrubbing them too is Clinger’s S_sfs, a scrub per step, and not this decision. - U-β.
Force(Thunk M)putsMin focus under the current environment: a literal!{ … }closes nothing and walks nothing. - No native reads a lexical environment.
Toalone holds anEnv, the one frame with syntax to close over it;Apply,TryandGuardcarry none, andBuiltinBody::Scopedis gone. explainandhelpreflect on the session.explain nameanswers for the session’s namespace —shell.envand the shell’s registries. A block-local or a lambda parameter is not a session name and never had a scheme there.- A worker starts from its creator’s session and forces its thunk.
spawn,watch,serviceand a pipeline stage seed the child’sshell.envfrom the spawning shell’s session; the body’s capture rides in its closure.useandexplaininside a worker read the session it was born into, sospawn { explain x }and!{ explain x }agree. - Rows shrink; values are not shared across them. A seed’s rows are per closure and hold what each mentions. A value two closures mention is written in both rows: deduplicating it is the sharing-preserving encoding’s job, not this decision’s.
What it costs
Every thunk value walks its body, looks up each occurrence and builds a small
map where it once bumped an Arc: every thunk literal evaluated, every
decoded and every scrubbed closure. Every recursive call builds one sibling
per group member, each walking the whole group. The wire’s root scan is
linear, so interning is O(roots²) with roots about the closures in a message.
No benchmark gates it.
Alternatives rejected
FVcached on thethunkandrecnodes — a memo of this walk: cheaper closures, two annotated IR structs.- Scrubbing at
closealone — a law that holds by audit, not construction. explainas a special form.Scopedovershell.env— dynamic scope in lexical clothes.- A worker whose session is its capture — session reads would see only what the body mentions.
Refines the-evaluator-steps-closures: its O(1) capture is a scrub, and what is in focus is a computation closure, a type apart from the thunk value.
Plan: dev/docs/plans/260926_free_variable_capture.md. Narrative:
evaluator-machine; residency:
binding-leases.