diff --git a/BUILT.md b/BUILT.md index 1fe5847..4a0cd5f 100644 --- a/BUILT.md +++ b/BUILT.md @@ -1127,6 +1127,221 @@ of those calls goes through a cell. memory of the process it is talking to, which is only guaranteed if it is the session that compiled the running binary. Attaching to a process someone else built is not a thing to support by default. +## Allocators, `(Vec T)` and `StorageExhausted` + +`spec-memory.md`'s Allocators section is frozen and settles what to build. This is why the built thing is the shape it +is, and — separately and loudly — the three places it **amends** that section plus the one thing it adds to it. + +### `Allocator` is a builtin opaque type, so none of milestone 5 was needed + +The spec defines an allocator as "a procedure plus an opaque data pointer", which reads as a function value, and +`check.ml` refuses function values four ways — a written `(Fn ...)` annotation, a written `fn` literal, a `defn`'s name +in value position, and calling anything other than a named function. Read straight off those lines, containers need +function values and the work doubles. + +None of the four is anywhere near this. All four are about *surface syntax*, and a value the compiler builds that no +surface form names trips none of them: + +- `Allocator` is a `Types.t` case with no user-writable constructor, the way `string` is a builtin ptr+len. No Flan + type names its procedure. +- The procedure is a C symbol the emitter names. +- `vec-new`, `push`, `free`, `free-all` and the rest are ordinary named calls, which `check_call` already routes + through `named_call`. + +The precedent was already in the repo twice: a `handler-bind` clause is lowered to a function called back through +`h->fn(condition, xfer)` and built as its own `Tast.fn`, never an `Ast.Fn`; and a dev build's `call` loads a pointer +out of a cell and calls through it, which is the indirect call the surface language refuses. + +What *does* need milestone 5 is a **user-written** allocator: "here is my proc, make an `Allocator` from it" wants a +`defn`'s name in value position. `make-allocator`, `allocator-from` and `allocator` are refused by name with that +reason, rather than coming back as unknown functions. + +**An `Allocator` value is a pointer to the runtime's struct, never a copy of one.** That is forced, not chosen. The +capability set has to be readable from wherever a container landed, and `free-all` bumps an epoch every container made +from the allocator has to observe. A copied-by-value allocator gives each copy its own epoch and the dev trap never +fires. + +### The surface + +| Name | What | +|---|---| +| `(heap-allocator)` | the general-purpose tier: malloc, aligned, with free | +| `(arena-new bytes)` | a bump arena over one fixed backing buffer | +| `(arena-destroy a)` | hands the pages back — see the amendment below | +| `(free-all a)` | releases everything the allocator holds, retain-capacity | +| `(can-free? a)` / `(can-free-all? a)` | the capability set, read at run time | +| `(alloc-epoch a)` / `(alloc-id a)` | the counter `free-all` bumps; the allocator's identity | +| `(alloc-budget a)` / `(set-alloc-budget a n)` | a ceiling on live bytes — an addition, see below | +| `(alloc-live-blocks a)` | "did you forget to free", answered at the tier that can answer it | +| `context/allocator` / `context/temp` | the current implicit allocator, and the per-frame arena | +| `(with-allocator a body...)` | rebinds for a dynamic extent and releases nothing | +| `(vec-new T)` / `(vec-new T a)` / `(vec-new)` | a Vec, against the context or a named allocator | +| `(push v x)` / `(reserve v n)` | `Unit`, both | +| `(at v i)` / `(len v)` | the array names, extended — not a parallel pair | +| `(as-slice v)` / `(as-slice v lo hi)` | a non-owning `[T]` view | +| `(clone v)` / `(clone v a)` | the only copy; assignment moves | +| `(free v)` | consumes its argument | + +### Three amendments to a frozen spec, and one addition + +**1. `free-all` is retain-capacity, and `arena-destroy` is the operation that hands pages back.** The spec's table has +`free-all` and nothing else. Zig's `ArenaAllocator.reset` takes a `ResetMode` of `free_all` / `retain_capacity` / +`retain_with_limit`; Odin's `arena_free_all` is retain-capacity in effect, because its arena is one fixed backing +buffer and the call only sets `offset = 0`. For a frame arena reset every frame, retain-capacity is the normal case and +handing the pages back only to ask for them again is the unusual one. Taking the mode as a parameter would have grown +the table the spec froze at four names; a second operation does not. The epoch is bumped either way — the pages being +the same does not make a container built before the reset valid, which is the whole point of the trap. + +**2. `context/allocator` and `context/temp` are dynamic variables with save and restore, not extra parameters.** The +spec says the allocator is "part of the calling convention". The literal reading touches every function signature, the +FFI shim, the dev trampolines and the reload ABI, for the same observable behaviour, and it collides with every other +lane working in `emit.ml`. The dynamic variable is the implementation; the literal reading is deferred and is a +performance question (a parameter avoids a global load), not a semantic one. + +**3. The `Vec` header is six words in every build, not four in release.** The spec fixes the release layout at +`ptr + len + cap + allocator` with the generation word and the epoch dev-only. A layout that changes with a build flag +is a layout that can disagree *silently* across the reload boundary: a redefinition module is built by `llc` and `ld` +against a host built separately, and nothing makes the two agree on a struct size. So the words are unconditional and +so is the epoch check. The 32-byte release layout is deferred on that, and it needs the reload path to carry the flag +before it can land. + +**The addition: a budget.** `flan_allocator` grew a ceiling on live bytes, 0 for none. The spec's `retry` restart is +answerable only by a handler that can make the *same* request succeed, and for a fixed backing store the handler that +works is the one that raises the ceiling — releasing the region the container lives in invalidates the container, which +is exactly what the epoch check catches. The spec names "grows the arena and then invokes `retry`" as the handler that +works; something has to be growable for that sentence to be true. It doubles as how a test exhausts an allocator on +purpose. + +### `with-allocator` is its own IR node because of the transfer path + +Save, run, restore — and *restore again at the pad*. That second restore is the whole reason it is a node rather than a +`let` and two calls. A body that errors, or one a handler transfers out of, leaves through `current_pad`, and a context +allocator left pointing into a region nobody outside the body has heard of would be wrong in the break loop, which is +precisely where something is about to allocate in order to render a condition. `test/programs/allocators.flan` asserts +that path by taking a restart out of a `with-allocator` body. + +It releases nothing, per the spec: not at the end of a `let`, not at the end of a function, not at the end of the body. +The program proves it by reading the epoch either side. + +### `(Vec T)`: one type-erased runtime, and the backend learned almost nothing + +The element type appears nowhere below the call site. `size_of` and `align_of` are produced where the concrete type is +known — which without generics is simply the concrete call site — and passed in, which is Odin's arrangement +(`base/runtime/dynamic_array_internal.odin`). The backend grew four prims in total: + +- `Rt of string` — a call into the runtime's C named by symbol, with argument and result LLVM types read off the + expression nodes. The container runtime is type-erased and therefore *is* a list of C entry points, so one arm covers + all of them. A `Vec` argument crosses as its address, which is also what lets an operation mutate the caller's Vec. +- `SizeOf` / `AlignOf` of a type, filled in from the same layout calculator DWARF uses — the one the acceptance test + already checks against LLVM's own `getelementptr` answers. +- `AddrOf` of any expression, place or not, because the element a `push` copies may be computed. The backend already + spilled a non-place to a temporary for exactly this. + +`at` and `len` were already the names for a fixed array and a slice, so a Vec extends them rather than adding a +parallel pair — the asymmetry `nth` was removed for. The value form `(at v i)` and the place form `(set (at v i) x)` go +through one helper, so they cannot drift apart the way `nth` did. + +A Vec's length and index are `i32`, like every other length here. Widening indices is one change across every +container and not a Vec question. + +`let` has no type annotation — `parse.ml` settles that a triple binding is ambiguous and that types are inferred — so a +local Vec has nowhere to say what it holds and the element type is written at the call: `(vec-new i32)`. This is *not* +the explicit instantiation syntax the generics section rules out: nothing here is generic, and the name resolves as an +ordinary type rather than binding a type variable. Where the context does say — a `defvar`'s type, a return type, an +argument — it may be left out. + +A zeroed Vec has a null allocator, and the first operation that needs storage adopts the context allocator, which is +Odin's behaviour. The alternative was refusing a Vec-typed struct field outright; that is refused anyway, for a +different reason (below), but the adopt rule is what makes `Zero` of a Vec a usable value rather than a null deref. + +### Move-only is a dead set, and it is flow-sensitive at a join + +Reading a move-only local is a move unless the site said it was a borrow. That is the conservative direction: passing +one to a function, binding it, returning it and `free`ing it are all moves and all reach one place, and the handful of +operations that only look at a container (`at`, `len`, `as-slice`, `push`, `reserve`, `clone`) say so. Only a +*syntactically simple* target counts as a borrow — in `(len (f v))` the call still moves `v`. + +At an `if` and at a `match`, every arm is checked from the state before the form and the **union** of what they moved +survives the join. A flat set is wrong in both directions: it refuses `(if c (free v) (free v))`, which is legal, and it +accepts a use after a one-armed move, which is a use-after-free. The arms are alternatives, and that is what a union +says. + +The one case a dead set cannot answer is a move inside a loop: merged once at the end of the body it counts one move, +not two, while the second iteration would use what the first gave away. So it is a rule rather than an inference — a +move of a binding declared outside the loop is refused, with that as the reason. + +### What ownership is not transitive through yet, and why each is refused + +The spec says ownership is structural — a struct containing a Vec is itself move-only, `free` recurses into owning +fields, and a field cannot be freed on its own. That machinery is the recursive teardown `drop` brings. Until it lands, +three shapes are refused where they are declared, each naming `drop`: + +- **a struct field of `Vec` type**, because the struct copies its header on assignment and nothing records a move; +- **a global of `Vec` type**, because the dead set is per function — two functions each freeing it is a double free + nothing could see, and a global read does not go through the move path at all, so even the one-function case would be + accepted. Half a rule is worse than none. A global **`Allocator`** is a different thing and stays legal: an allocator + is a copyable opaque handle, and it is what makes a handler that owns the arena expressible, since a handler cannot + see the locals of the function that established it; +- **a `Vec` of a `Vec`**, because the type-erased runtime copies and releases elements bytewise: `clone` would + duplicate inner headers instead of copying what they own and `free` would drop their buffers. + +And a `Vec` does not cross to C: handing a header that owns storage to C hands out an owner. `(as-slice v)` as +`(Ptr T)` plus `(len v)` is the shape that does cross, and the refusal says so. + +### `StorageExhausted` went in *with* `Vec`, not after it + +No allocating operation returns an error and none can fail silently. When the allocator cannot satisfy a request the +operation signals `(StorageExhausted {:bytes n :align a :allocator id})` with `error` — whose type is `Never` — inside a +`restart-case` offering `retry`. One rule over every allocating operation, which is what keeps `push` and `reserve` at +`Unit`, `clone` at the container, and no signature anywhere growing a `Result`. + +It had to land with step 2 rather than after it: retrofitting adds a transfer check to every call site of every +allocating operation, which is the point of having decided it first. Odin's `append` returns an ignorable +`Allocator_Error`, and its type-erased path returns the old length on a failed reserve; an append that appends nothing +and says nothing is the outcome this rule exists to make impossible. + +The lowering is built out of nodes that already existed, so the backend learned nothing about allocation: + +``` +(let [ok false] + (while (not ok) + (restart-case + (do (set ok ATTEMPT) + (if (not ok) (error (StorageExhausted {...})))) + (retry [])))) +``` + +A handler that frees something, releases a scratch region or raises the ceiling and then invokes `retry` lands in the +clause, the clause falls through, and the `while` re-attempts the **same** request. Every argument to the attempt is +bound to a slot before the loop, so a retry re-attempts the allocation and not the expression that produced the value a +`push` was given. With nothing handling it, `error` stops the program on the frame that erred. + +This is the named exception to plan.org's "restarts go at the resync point, once" — the restart is established at the +failing allocation, because a restart at some outer loop cannot re-attempt an allocation and only the allocation site +can. The condition is a value struct with fixed numeric fields and **no rendered message**, because formatting would +allocate and this is the one path that must not; the numbers of the failed request are read back out of the runtime. + +### The epoch trap, which is the shipping answer to an open question + +`spec-memory.md` leaves "catching a use-after-release statically" open on purpose: the static rule needs to know which +allocator a construction used, and `with-allocator` plus `context/allocator` are exactly the mechanisms that deny that +knowledge. The shipping answer is detection. A Vec records the epoch of the allocator it was made with, `free-all` bumps +that counter, and any operation on a container whose recorded epoch has moved traps naming the site. +`test/programs/stale-region.flan` is the case, and the point of it is that `v` is still in scope, still looks fine, and +nothing marked it — which is precisely what a static rule cannot see. + +**The generation word has no reader.** It is bumped on every reallocation, as specified, and the stale-slice trap it +exists for is not implemented: a slice is ptr+len and has nowhere to carry the Vec's identity or its generation. Said +plainly here rather than implied by the word's presence in the header. + +### What this leaves for steps 4 to 7 + +`(Map K V)`; `drop` and with it the transitive move-only rule, recursive teardown, and the refusal to construct a +drop-carrying container against an allocator without `can-free`; `(Result T E)` and `try`; generics; the macro expander. +And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an enclosing Vec — which `Vec` does not buy: +capture does not exist at all, and the spec's captured-`Vec`-by-pointer rule has never had to exist because every +capturable type today is a value type. It is its own item and should be planned as one. + ## Where build time goes `flan build calc-me.flan` was ~160ms, and ~95% of it was clang. **The object cache is in**, and it is now ~110ms: diff --git a/NEXT.md b/NEXT.md index 1fc9a6e..59d1ab9 100644 --- a/NEXT.md +++ b/NEXT.md @@ -62,6 +62,16 @@ the commit that made it. `-2851001042534928384` — the same 64 bits, printed unsigned now that `hash-grid`'s `u64` no longer goes through an `(i64 …)` cast — and a trap column shifted because the call it names got shorter. +### Landed — the allocator, the arena, `(Vec T)`, `StorageExhausted` + +The critical path, and the thing NEXT.md said was the only one standing between this and writing a game. Steps 1, 2 and +3 of the build order below are struck; `Map` is step 4 and is untouched. `Allocator` is a builtin opaque type and +needed nothing from milestone 5, which was the whole bet. Three amendments to a **frozen** `spec-memory.md`, made +deliberately and stated as amendments in [`BUILT.md`](BUILT.md): `free-all` is retain-capacity with `arena-destroy` +beside it; `context/allocator` is a dynamic variable rather than a literal calling-convention parameter; and the `Vec` +header is six words in every build rather than four in release. One addition the spec does not have: a budget on the +allocator, because `retry` needs a handler that can make the *same* request succeed. + ### Landed — the runtime under a sanitizer `--sanitize` is a build flag beside `--debug`; `dune build --root . @sanitize` builds twenty-eight programs twice, plain @@ -321,8 +331,10 @@ What stopped it, and neither is small: plan.org's single line on it (831) names a `for` the language does not have and gives no mechanism. -1. **Allocators, then `Vec` and `Map`.** The critical path, and the only thing standing between this and writing a - game. **`Vec` does not need generics** — that was wrong and is worth un-learning: Odin's containers are compiler +1. ~~**Allocators, then `Vec` and `Map`.**~~ **Steps 1, 2 and 3 are done** — the allocator, the arena, `(Vec T)`, + `StorageExhausted` and `retry`. `Map` is step 4 and is what is left of this item. See *Allocators, `(Vec T)` and + `StorageExhausted`* in [`BUILT.md`](BUILT.md) for the shape, the three amendments to a frozen `spec-memory.md` and + the one addition. The claim below held: **`Vec` does not need generics** — that was wrong and is worth un-learning: Odin's containers are compiler builtins over a *type-erased* runtime (`base/runtime/dynamic_array_internal.odin`), where `$T` appears only in thin wrappers producing `size_of`/`align_of` at the call site, and per-key hash and equality are compiler-emitted procedures passed as a runtime argument (`Map_Info`, `base/runtime/core.odin:369`). That runtime is what @@ -349,14 +361,18 @@ plan.org's single line on it (831) names a `for` the language does not have and ### `Vec` and `Map` — the order to build them in +**Steps 1, 2 and 3 are built; 4 to 8 are what is left.** The reasoning is kept because it is what the remaining steps +rest on, and because the escape it describes was tested rather than assumed — see *Allocators, `(Vec T)` and +`StorageExhausted`* in [`BUILT.md`](BUILT.md). + The dependency nobody had written down, and the reason it looked worse than it is. `spec-memory.md` defines an allocator as "a procedure plus an opaque data pointer" — a function value. `check.ml` refuses function values four -ways, and all four say milestone 5: a written `(Fn ...)` annotation (`Ast.Tfn`, line 209), a written `fn` literal -(`Ast.Fn`, 458), a `defn`'s name used as a value (571), and calling anything other than a named function (1023). -`(Vec T)`, `(Map K V)`, `(Result T E)` and `(Handle T)` are refused at 215–218 as milestone 6. Read straight off those -lines, milestone 6's allocators need milestone 5's function values and the work doubles. +ways, and all four say milestone 5: a written `(Fn ...)` annotation (`Ast.Tfn`), a written `fn` literal (`Ast.Fn`), a +`defn`'s name used as a value, and calling anything other than a named function. `(Map K V)`, `(Result T E)` and +`(Handle T)` are still refused beside those as milestone 6; `(Vec T)` is not, any more. Read straight off those lines, +milestone 6's allocators need milestone 5's function values and the work doubles. -**The escape is real and the work does not double.** All four refusals are about *surface syntax*, and a value the +**The escape is real and the work did not double** — this is the claim the built thing confirms. All four refusals are about *surface syntax*, and a value the compiler builds that no surface form names trips none of them. The compiler already does exactly this, twice: - A `handler-bind` clause is lowered to a function whose address goes into a `flan_handler` and is called back through @@ -386,28 +402,27 @@ and that order cannot hold: the macro expander is blocked on `Form` being a Flan numbers are a topological hint, not a sequence. Take 6's container half first, 5's generics half second, and 5's expander last, on 6's unions. -1. **`Allocator` and the arena.** The builtin type, the four operations (`alloc`, `resize`, `free`, `free-all`) with - `size` and `align` as parameters, the capability set with `can-free` in it, `with-allocator`, and the dev-build - epoch counter. No container yet. Odin reads its capability set back through the same procedure — `Query_Features` - returning an `Allocator_Mode_Set` (`core/mem/allocators.odin:315`) — and a field on the allocator value is the same - information without the round trip. -2. **`(Vec T)`**, over the type-erased runtime: `ptr + len + cap + allocator` in release, plus the generation word and - the epoch in dev. `push`, `reserve`, `at`, `len`, `as-slice`, `free`, `clone`. `size_of`/`align_of` are produced at - the call site, which here is simply the concrete call site, there being no generics yet. +~~1. **`Allocator` and the arena.**~~ **Done.** The builtin opaque type, the four operations with `size` and `align`, + the capability set read off the allocator value, `with-allocator`, `context/allocator`, `context/temp` and the epoch + counter. `free-all` was decided as retain-capacity with `arena-destroy` beside it; the context is a dynamic variable + rather than a literal calling-convention parameter; both are stated as amendments in `BUILT.md`. A user-written + allocator is refused by name with milestone 5 as the reason. + +~~2. **`(Vec T)`**~~ **Done**, over the type-erased runtime, with `push`, `reserve`, `at`, `len`, `as-slice`, `free` and + `clone`, and with move-only enforced by a dead set that unions at an `if` or a `match` join. `at` and `len` were + extended rather than duplicated. The header is six words in *every* build, not four in release — a layout that + changes with a build flag can disagree silently across the reload boundary — and that is the third amendment. + Ownership is not transitive yet, so a struct field of `Vec` type, a global `Vec` and a `(Vec (Vec T))` are each + refused where they are declared, naming `drop` as what they wait on. + + The note below still stands and is now the only thing between `Vec` and the accumulation pattern: **capture does not + exist at all.** Nothing about it changed. + +~~3. **`StorageExhausted` and `retry`**~~ **Done, with step 2 and not after**, exactly for the reason given. It is a + `while` around a `restart-case` around the attempt, built in the checker out of nodes that already existed, so the + backend learned nothing about allocation. `test/programs/exhausted.flan` exhausts an allocator for real and takes the + restart; `exhausted-unhandled.flan` is the same failure with nothing handling it. - **`Vec` alone does not buy the accumulation pattern, and it is worth knowing that before step 2 is scoped.** - `(fn [c] (push errors c) ...)` over an enclosing `(Vec ParseError)` is the pattern `handler-bind` exists for, and it - needs two further things. Capture does not exist at all: `check.ml`'s `captured` (`:176`) consults `ctx.outer` only - to raise a better refusal — "a handler cannot see %s ... Use a global, or pass it on the condition" — and `lookup` - (`:167`) never reads `outer`. That is `spec-memory.md`'s case 2, a non-escaping `fn` capturing by value into a stack - environment, and it is unbuilt. On top of it, the same spec says a captured `Vec` or `Map` is captured **by pointer, - not moved**, and every capturable type today is a value type, so the by-value/by-pointer split has never had to - exist in a capture path. Both are their own work and neither falls out of `Vec`. Step 2 delivers a container; - accumulating into one from a handler is a separate item and should be planned as one. -3. **`StorageExhausted` and `retry`, with step 2 and not after.** `restart-case` and the transfer channel exist, so - this is a compiler-emitted restart at each allocating site and little else. It goes in at the same time because the - signatures depend on it: retrofitting it later adds a transfer check to every call site of every allocating - operation, which is the whole point of having decided it now. 4. **`(Map K V)`** — flat open-addressed key and value arrays, with a compiler-emitted hash and equality pair per key type passed as arguments. `spec-memory.md`'s structural-key restriction holds this to the built-in key set, so there is no dispatch to design. @@ -421,21 +436,26 @@ expander last, on 6's unions. of the second. 8. **The macro expander**, last, on 6's unions. -**What is genuinely unsettled, and none of it blocks step 1.** +**What is genuinely unsettled.** -- `spec-memory.md`'s "Open: catching a use-after-release statically" is open by decision, not by omission. It says the - static rule needs to know which allocator a construction used and that `with-allocator` plus `context/allocator` are - exactly what deny that knowledge; the shipping answer is the dev-build epoch trap, which is specified and buildable. - It also says what would settle it — real Flan programs using arenas, to show whether the escapes that occur are - lexical — and that is evidence this repo cannot produce until after step 2. **Build against the epoch trap.** -- **The operation table may be one operation short.** It has `free-all` and nothing else. Zig's `ArenaAllocator.reset` - takes a `ResetMode` of `free_all`, `retain_capacity` or `retain_with_limit` - (`lib/std/heap/arena_allocator.zig:57`–`69`), and for a frame arena reset every frame, retain-capacity is the normal - case and free-all is the unusual one — handing the pages back to the backing allocator only to ask for them again. - Odin's `arena_free_all` is retain-capacity in effect, because its arena is one fixed backing buffer and the call only - sets `offset = 0` (`core/mem/allocators.odin:287`). Flan should decide whether `free-all` means either of these or - takes the mode. It is an amendment to `spec-memory.md`, it is small, and it is better made before the arena is - written than after. +- `spec-memory.md`'s "Open: catching a use-after-release statically" is still open, and it is now open with evidence + available for the first time: the epoch trap is built and `test/programs/stale-region.flan` is the case it catches. + What the spec says would settle it — real Flan programs using arenas, to show whether the escapes that actually occur + are lexical — is now *producible*, because there is a `Vec` to write them with. That is the next thing to look at, + not the next thing to build. +- ~~**The operation table may be one operation short.**~~ **Decided.** `free-all` is retain-capacity and + `arena-destroy` hands the pages back — two names rather than the mode parameter, so the table the spec froze at four + operations did not grow. `BUILT.md` states it as the amendment it is. +- **The `Vec` header is six words in release too, and should not stay that way.** The 32-byte layout the spec fixes is + blocked on one thing: a redefinition module is built by `llc` and `ld` against a host built separately, and nothing + makes the two agree on a struct size. Give the reload path a way to carry the build flags and this falls out. +- **The generation word has no reader.** It is bumped on every reallocation as specified, and the stale-slice trap it + exists for needs a slice that can carry the Vec's identity — a slice is ptr+len. Either slices grow a word in a dev + build or the trap does not exist; today it does not. +- **The allocator grew a budget** (`alloc-budget` / `set-alloc-budget`), which `spec-memory.md` does not have. It is + there because `retry` is only answerable by a handler that can make the *same* request succeed, and for a fixed + backing store that handler is the one that raises the ceiling — releasing the region the container lives in + invalidates the container. Worth folding into the spec or replacing with a growable arena. - Escaping closures are still deferred (`spec-memory.md`, "Function values", case 3), and a user-written allocator is not one — its procedure is a top-level `defn` with no captured environment. The two should not be conflated when function values arrive.