Lazy computation and reclamation specification
Added 2026-10-22Source: local draft in this repository (added after the documentation export)
Draft 0.1. The language is strict call-by-value, and stays so: there is no implicit general laziness. This chapter adds an explicit laziness layer that does not require a garbage collector. Every deferred value states, in its type, when its captured environment dies. MUST denotes a requirement of this draft. Proposed choices are identified explicitly; unresolved items are listed in Open decisions.
1 The problem laziness actually poses
Deferring a computation is cheap: a closure already does it. The hard part is reclamation. A deferred value keeps its captured environment alive until the value is either forced or destroyed, and with sharing it may be kept alive across scopes that the language cannot predict. A garbage collector answers “when is the environment dead?” at runtime. This design answers it statically instead: every lazy value carries a reclamation policy among three deterministic options, and no fourth is needed.
| Mechanism | Sharing | Death time of captures | Needs GC |
|---|---|---|---|
Affine thunk Thunk {e} a |
None: force consumes | At force, or at destruction | No |
Region memo cell Lazy {e} r a |
Yes, inside region r |
Region teardown | No |
Duplicable thunk %many |
Copies recompute | Each copy’s own consumption | No |
Two capabilities must not be conflated: deferring work (cheap) and sharing one evaluation among many uses (needs a cell with a lifetime). The design makes the second explicit and scope-bound instead of universal.
2 What stays unchanged
Later aanddelay (\() -> expression)in the core specification remain the guarded observation mechanism for productivity and coinduction.Lateris a logical modality, may be erased, and carries no reclamation obligations. It is not an unrestricted thunk and MUST NOT be given one.delayandforceremain ordinary library names, not control syntax, as Surface syntax and grammar states. The forms in this chapter are prelude types and operations plus the~binding form inletandwhereblocks (section 5).- Evaluation of executable code stays strict and left-to-right. A function’s strictness never changes because a thunk exists somewhere in its type.
3 Affine thunks
-- The effect row of the body is part of the thunk type.Thunk :: {e} -> Type -> Type
delay :: forall a e. (() ->{|e} a) -> Thunk {e} aforce :: forall a e. Thunk {e} a ->{|e} adestroy :: forall a e. Thunk {e} a ->{DestructorEffects e} ()delay fallocates a closure capturingf’s environment under the ordinary closure rules: owned captures transfer, borrowed captures keep their lifetimes (“a callback cannot outlive a borrowed environment” applies verbatim).forceconsumes the thunk. A thunk is used at multiplicity%1or%affby default (the multiplicities of Surface syntax and grammar apply). With%1, every path, including error and cancellation paths, must force or destroy the thunk exactly once; with%aff, at most once. Destroying an unforced thunk runs the destructors of its captures, deterministically and in reverse capture order.- Because forcing consumes, there is no memoization cell and no sharing question: the body runs at most once. This is laziness without a GC and without a cache.
- Effects are honest.
forcehas exactly the effect row of the body.Thunk {e} acannot hideIO, failure or suspension inside a pure type, matching “an explicit pure signature cannot hide them”. Divergence appears ineasDivergewhenever the body’s termination is not established; forcing a thunk is never a way to run a diverging computation while claiming totality. - A thunk is a value in
Type. Forcing is computation, so erased evidence may live inside a thunk, but the erasure rules of the core specification apply to the body unchanged: a failed check inside a thunk produces no evidence, and erasure must not remove a force whose effects are observable.
This level covers the common need: “compute this later, at most once, and clean up deterministically”, for example validating an expensive argument only on the path that uses it, or building an error payload only when the error happens.
4 Duplicable thunks
A thunk used at multiplicity %many is duplicable and unshared: every duplicate re-runs the body. Copies are ordinary owned values; each dies with its owner. This gives call-by-later semantics for total pure code where recomputation is acceptable, without a cell and without a collector.
Sharing one evaluation among many uses is a different feature and is section 5’s. The compiler MUST NOT silently turn a %many thunk into a shared memo: that would introduce a hidden lifetime.
5 Region memo cells: Lazy {e} r a and ~ bindings
For call-by-need with sharing, evaluation state must live somewhere with a reclamation time. It lives in a region, like the Ref r a and Mut r a of the core specification.
Lazy :: {e} -> Region -> Type -> Type
-- `~` bindings work in `let` blocks and `where` blocks alike:-- f x = y * 2-- where ~y = expensive xdemand :: forall r a e. Lazy {e} r a ->{Eval r | e} Ref r a- The memo binding form is
~ pattern = terminside aletblock or awhereblock. The bare~is reserved punctuation, like$,=and|, so it is not an operator; symbol operators such as~>and<~>remain available. In Haskell~pmarks an irrefutable pattern; here~marks the memo binding itself, and the shared intuition is the same: the binding is not evaluated eagerly. All~bindings of one block form one memo group: one fresh regionr, one effect labelEval r, mutually visible cells.let ~x = e in bodyis the single-binding case of the same form. A bare use of a~-bound name elaborates todemand, sof x = y * 2 where ~y = expensive xdemandsyat that use; holding a cell undemanded means storing theLazy {e} r avalue explicitly. - A memo cell’s type records the effect row
eof its body.demandhas the rowEval r | e: the first demand runse, later demands run nothing, and a demand in effect-polymorphic code simply carriesealong. The row is an over-approximation on every demand; refinement may prove later demands pure. - A
~group in awhereblock is scoped to one evaluation of the equation. The cells are allocated at call entry, are visible in the equation body and in the block’s other bindings, and the whole group is reclaimed at return, in reverse allocation order. Two calls of the same function own disjoint groups. - Strict
wherebindings evaluate in declaration order at call entry and may see earlier strict bindings and every~binding of the block. A~body may see all bindings of the block. If a strict binding demands the group and a~body refers back to that strict binding, the composition carries the group’sEval rinto the cell’s row and is rejected exactly like a direct cycle (section 6). - The first
demand xevaluatese; later demands observe the same value.xMAY be used at multiplicity%many. - The cell’s captures die at region teardown: for a
whereblock group, at return from the call; for aletblock group, at the end of the enclosing block. Teardown reclaims the cells and everything only they own, in reverse allocation order, running destructors then. Regions can reclaim cycles, so mutually referential cells are reclamation-safe. Lazy {e} r adoes not outliver, exactly likeRef r a. It may be stored in data structures that are themselves parameterized byr; it may not escape them. A value needed beyond its region must be forced and moved out, or rebuilt as an owned thunk.- Because a cached value is observed many times, the cached type
aMUST be duplicable (an immutableSharevalue). Linear or affine resources use section 3’s affine thunks instead: lazily opening a file is a%affthunk, not a memo cell.demandreturns a borrowRef r aso the cached value is never silently duplicated; the caller copies explicitly when it needs an owned value. - Re-entrancy: a cell has states
Unevaluated,Evaluating,Evaluated. Demanding a cell that isEvaluatingis a black hole. Section 6 rules black holes out statically through theEval reffect and defines the runtime fault for the paths that cross a trust boundary. - Concurrency: cells are single-threaded by construction and belong to the scope that allocated them. Moving a thunk or a demand across task boundaries follows the ownership rules of Concurrency specification: an affine thunk moves by ownership transfer, a
Lazy r adoes not cross its region’s thread. A thread-shared memo needs an explicit synchronized structure, not this mechanism.
6 Black-hole freedom and the demand effect
Demanding a cell while it is evaluating is a black hole. Two rules rule it out: a static discipline that makes it impossible in well-typed code, and a defined runtime fault at the boundaries where the discipline can be bypassed.
Demand is an effect. Every memo region r has a fresh effect label Eval r, and demand performs it (section 5). Creating a thunk performs nothing; force performs its thunk’s row. Effects are honest, so the evaluation row of a cell body records every demand that body can perform, directly or through higher-order code, exactly as IO, Cancel and Diverge propagate through rows today.
The rule. For a ~-binding memo region r, the effect row of every cell’s evaluation MUST NOT contain Eval r. Demanding a cell of a different region is fine. Storing a thunk that demands r without forcing it is fine: the thunk’s Eval r sits inside Thunk {Eval r | e} a and runs only when someone forces it, which is later than the cell’s evaluation.
Why it is sufficient. A black hole requires a demand of a cell of r during the evaluation of a cell of r. By effect soundness that demand’s Eval r appears in the evaluation row of the cell body, and the rule forbids exactly that. The argument needs only that effects are honest, which the language already requires. It needs no termination, sized-type or productivity analysis.
Discharge and scoping. The memo construct binds r like a region binder and discharges Eval r from the residual row at scope end; it is, in effect, a built-in handler of its own label. This is sound because nothing that can perform Eval r escapes the scope: Lazy {e} r a, Ref r a and Thunk {Eval r | e} a all mention r in their types and cannot outlive it (section 5’s escape rule). The effects of evaluating cells themselves are not discharged: IO, failure and Diverge run at first demand and propagate to the enclosing row like any other effect.
Recursion and re-entry. A function whose body uses ~ bindings in its where block has a public effect row that does not mention its group’s Eval r, so a cell body may call its own function recursively: the recursive call allocates a fresh group and reaches the caller’s cells only by handle. Handing a Lazy {e} r a handle to such a call, or capturing one in a closure it runs, makes the demand of r visible in the row again and is rejected. The label is per binding site, so this covers re-entry across activations of the same function, not just a single cell instance.
Why guarding with delay alone is not sufficient. Syntactic guardedness does not help: let ~x = force (delay (\() -> demand x)) has a cycle through a delay, and still re-enters x on the first demand. What defuses a cycle is deferring the demand past the cell’s evaluation, and the effect row is what detects whether that actually happened.
What the rule rejects. It is conservative in one direction: demanding a different cell of the same region during evaluation is a dependency, not a cycle, and is rejected too. The program-level fix is structure, not annotation: put acyclic dependencies in separate ~ groups, since each group gets its own region name and its own Eval label, or name the memo region explicitly and split it. Per-cell labels would be finer and are not proposed in this draft.
Runtime fault as defense in depth. The cell states of section 5 remain, and a demand that finds Evaluating fails with a defined BlackHole fault, in the same policy class as stack exhaustion and allocation failure (core specification, section 6). Well-typed code cannot reach it; code that crosses a declared trusted assumption, a foreign boundary, or a runtime bug can. A deployment profile claiming the fault cannot occur MUST discharge the effect discipline and record its trust boundaries in the assumption manifest.
Relation to divergence. A black hole is morally an infinite regress. In code whose row already contains Diverge, a cycle is one more way to diverge; the BlackHole fault remains its observable behavior. Diverge does not grant Eval r, so the rule still applies.
7 Data structures and unboxed values
- Fields are strict by default. A field MAY be
Lazy {e} r a, and then the structure’s type mentionsrand the body rowe; implicit lazy fields with hidden regions are rejected. - Primitive unboxed values (see Primitive types specification) are never lazy: deferring an
I64computation is a thunk around the computation, not a lazyI64. - Streams and other codata keep using
Laterfor guarded productivity. An infinite stream is not a memo of its elements; unbounded memoization is exactly what a collector would be needed for and is not provided here.
8 Erasure, upgrades and trust
- Forcing a pure total thunk is an ordinary computation step; no runtime evidence survives erasure unless the core rules retain it.
- An unforced thunk pins the code version that will run when forced, exactly like a registered foreign callback. Runtime evolution specification obligations apply: an upgrade package must drain, migrate or retain thunks, and old code stays loaded while any thunk from it can still run. A memo cell that is already
Evaluatedpins nothing further. - The reclamation claims of this chapter are about deterministic deallocation, not about absence of leaks in the large: a region that is never exited keeps its cells alive, and a
%1obligation violated at runtime is a checked usage error, not a safety hole.
9 Required rejection cases
- An implicit lazy argument or lazy field whose region is not in its type.
- A
Thunk {e} awhose declared effect row omits an effect of its body, or a diverging body typed as total. - Forcing a
%1thunk twice, or destroying it after forcing; using an affine thunk after force; a%1obligation skipped on an error or cancellation path. - A
Lazy {e} r aused afterrends, or returned from the scope that definesr. - Demanding a
Lazy {e} r awhose cached typeais linear or affine. - A
~cell body (inletorwhere) whose evaluation row containsEval rfor its own group: a demand cycle, whether direct, through forcing a thunk, or through a strictwheresibling that demands the group (section 6). - Escaping a memo group: a
Lazy {e} r a,Ref r aorThunk {Eval r | e} ain an equation’s result, a returned closure, or any other value that outlives the group’s scope. - A thread-shared memo cell built from
Lazy {e} r aacross task boundaries. - An erased proof whose availability depends on whether a thunk was forced.
10 Open decisions
- The exact
Shareclass for duplicable values, and whether it is primitive or derived from the usage algebra. Core section 7 leaves immutable sharing unsettled; this chapter needs it. - Demand labels finer than one per
~group (per cell, or per dependency edge) so that acyclic demand inside one group is accepted. The group rule of section 6 is conservative in exactly this one direction; finer labels should wait until the group rule has been used in practice. - Whether
~should also mark irrefutable patterns in function arguments (Haskell’s~p), with the fields of such a pattern elaborating to thunks. This draft reserves~for the memo binding form only. - Whether
demandshould have an owned variant forSharetypes (forceShared : Lazy {e} r a ->{|e} awhena : Share), trading a borrow for convenience. - Destructor order at region teardown beyond “reverse allocation order” when cells borrow from each other.
- Interaction with
Later: whetherLaterobservations compile to erased thunks, sized structures, or something else remains the guarded-recursion decision of core section 9. - Cost model: whether the compiler may allocate a
~cell on the stack whenrdoes not escape, or elide the cell when demand count is statically one. These are optimizations and MUST NOT change observable reclamation.
Implementation status
The bootstrap compiler in compiler/ implements this chapter’s runtime shape: Thunk as a closure over Unit (delay, force), ~ name = term memo bindings with one memo group per block, demand-on-use elaboration, and the Eval r black-hole rule of section 6 enforced statically through row lacks constraints (cell bodies may not perform their group’s label). Not yet implemented there: the multiplicity discipline of section 3, region escape checking, Ref r a (the bootstrap demand returns the value), and the Share class, so a Lazy {e} r a value can outlive its region in the bootstrap, which is the same lifetime-checking gap the rest of the bootstrap has. The BlackHole runtime fault exists as defense in depth.
Amendments
Dated notes recording landed decisions (compiler/CONFORMANCE.md,
D-numbers) that amend or interpret this chapter’s normative text.
2026-10-28, P15 conformance sweep.
Lateris a usage-multiplicity rule (D36), 2026-10-28. This amends section 2:Later ais a distinct type former that never unifies withThunk; aLaterbinder lives at%1/%affonly (default%aff) andforceconsumes it;%manyis rejected as unrestricted-thunk semantics. Kinding:Latertakes exactly one type argument and no effect row.- Usage defaults and sharing (D15-D19), 2026-10-28. This amends
sections 3-5:
Thunk-typed binders default%affwhile unannotated binders default%many(D15); memo bare-use requires the cached type to beCopy, discharged throughcopyOut(D16/D19), anddemandreturnsRef r aso the cached value is never silently duplicated; lambda/thunk bodies count uses as unbounded and memo bodies at most once (D18). The bootstrap’s multiplicity/region gaps named in this chapter’s implementation status are closed for theCopy/Refstory by these rules (the runtime shape was already present). - Black-hole runtime and memo groups (D131-D137), 2026-10-28. The
runtime
BlackHolefault (exit 70) backs theEval rlacks rule of section 6 as defense in depth (runtime/src/abi.rs::cinch_blackhole);letrecfixpoint boxes use the same fault for an early read of an unevaluated knot, and the compile-time evaluator refuses fixpoint boxes with a named error (D136). - Cinch naming (D278-D281), 2026-10-28. The language is Cinch:
sources
.cinch, interfaces.cinchif, runtime externscinch_*; artifact names in this chapter read with those names.
