flan/FIX.org
Joseph Ferano 1440ae4119 Taking a re-run is what ends the park, so the state says so
flan_merged_rerun accepted a request under the lock and left program_state
as it found it: PROGRAM_PARKED, until the parked thread got round to waking.
describe's :parked reads that same state, so a caller that asks for a re-run
and then waits for the program to park again was liable to be answered by the
park it had just ended — the wait fell through on the old park, the next
request went out before the first run had started, and the pair of them made
one run between them; or the thread woke in between and the second was refused
as "already running". One lagging state, two symptoms, and all four of
test_dev's re-run sites could show either under load.

The window was documented over flan_merged_park as "as wide as a flush". It
stopped being that when the ring drain went in front of the park's exit: the
leaving round loads whatever was queued before it breaks, so the window was as
long as the next thing the program had to do.

The store moves to the acceptance, under the lock that made it. There is no
longer a moment in which a committed re-run reads as parked, and the park's own
store on the way back into main stays as the no-op that says where the thread
has got to. Dev.rerun reads the liveness and the break for its note before it
asks, since afterwards the answer is running by construction.

Measured on that loop driven standalone against programs/dev-rerun.flan: 9
failures in 25 runs under two busy-loop burners before, 0 in 50 under the same
load after. FIX.org, 2026-09-21, has the readers that were checked, the path
that cannot exist, and why the two-process daemon has no such window.
2026-09-21 12:02:20 +07:00

5716 lines
326 KiB
Org Mode
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

* Stuff I've found
** Why do I need to call flan to open another window?
let: flan: the program exited; restart flan dev
** I can't eval a toplevel defvar, need to eval-defun (C-c C-c)
For Flans intended live-program workflow, C-x C-e on any complete top-level form should do the natural thing:
- expression → compile/run temporary thunk; print its value
- defn, defvar, defmacro, etc. → compile/install it; report what changed
The compiler already has both paths. The current split is an editor/UI artifact: C-x C-e is wired directly to eval-expr, while C-c C-c is wired to declaration reload. It is not a fundamental limitation.
A good fix would make C-x C-e context-aware: if the enclosing form is top-level, send it through the declaration evaluator; otherwise use expression evaluation. Then C-c C-c can remain a convenient explicit “reload this definition” alias, but not the only way defvar works.
** I can't eval a top level Vec
slurp returns (Vec u8), an owning, move-only buffer. Flan currently forbids every move-only global because it has no global ownership/lifetime model: any function could read and free it, while ownership tracking only exists within one function.
For data that is fixed at build time, use an embedded immutable array instead:
(defconst the-data (embed "game-data.edn"))
That produces a fixed [u8], not a heap-owning Vec, so it can live globally. It also resolves relative to sand.flan.
If game-data.edn genuinely must be loaded at runtime, then today it has to be owned by a local—typically load it in main and pass it through the functions that need it. For a game-wide runtime-owned data asset, that is a missing language/runtime feature, not a bad use case on your part.
** The edn module seems to need a struct declaration, it should do both; go into a struct but also return a Map with Vecs and Sets when we don't provide a type
** defenum needs optional autoincrementing discriminants
** We need a javascript backend so we can reach the world
** We need to have C-style unions, maybe those are called defunion, and then sum types are defdata or deftype
* Decisions, 2026-09-17
** 1. Re-runnable main — DISPATCHED
The process does not actually die. [flan_exit_hook] is [flan_merged_exit]
(lib/dev.ml:2669): it flushes, reclaims fd 1, and parks in [for (;;) pause()].
What is missing is a way to wake it. The park becomes a condvar wait, a daemon
op signals it, and the main thread — not a new one, because raylib wants the
main thread — re-enters [flan_program_main]. [alive] gains a third state,
parked-and-re-runnable, and each of the ten guard sites decides for itself
whether it accepts one.
Globals are NOT reset between runs. That is the CL/Clojure semantics asked for:
the process never died, so a second (main) sees what the first one left.
Held for a zeroed global from the day this merged, and did not hold for a
computed one until 2026-09-20: re-entering main re-entered the startup function
that runs the computed initialisers, so every [defvar] with a call in it was
stored back over what the last run had left. Fixed by giving each computed
initialiser a guard of its own rather than by changing what a re-run does —
see "Per-form initialisation semantics on re-run" below.
** 2. C-x C-e on a top-level form — QUEUED behind 1
Same file as 1 (emacs/flan.el), so it waits rather than merging by hand.
No design questions; the note specifies it.
** 3. Runtime-loaded owning globals — DISPATCHED
Not a missing global ownership model. One rule: a move-only global is legal,
and reading one is always a borrow, never a move. Nothing takes ownership,
nothing frees it, its lifetime is the process's. Sound precisely because the
lifetime question has a constant answer.
Mutable in place as well — a global Vec can be pushed to. Aliasing follows
whatever locals already do; no new borrow regime for globals that locals lack.
[embed] (lib/check.ml:4265) still covers build-time data and is untouched.
** 4. edn both typed and dynamic — REDIRECTED to arenas, drop parked
Two projects, not one. (read-edn T bytes) does not exist — vendor/edn/edn.flan
is only a tokenizer, and the compile-time struct walk is NEXT.md item 9.
[drop] was dispatched to unblock the dynamic half and is being PARKED unmerged
on its branch, not reverted, because the premise was wrong. The refusal at
check.ml:599 is about *teardown*, not ownership: the type-erased runtime
releases slots bytewise and cannot walk a move-only element. An arena never
releases a slot — [free-all] takes the whole region — so the premise does not
hold there.
That is also what Odin does, which NEXT.md:1620 already recorded: no
destructors, no drop, no finalizers; [delete] frees container memory and
nothing else. core:encoding/json ships a hand-written recursive
[destroy_value] in the *library*, and the idiomatic alternative is to parse
against temp_allocator and [free_all]. Neither is a language feature. Building
[drop] was a departure from NEXT.md:1589's settled "defer stays the answer",
taken on the assistant's prompting and withdrawn.
So: lift check.ml:599 for arena-allocated containers, and let read-edn take an
allocator — which is already the idiom, since spec-memory.md:283 makes the
allocator part of the calling convention with an explicit override.
The real cost, stated because it is not free: [can-free] is a RUNTIME
capability on the allocator value while check.ml:599 is a COMPILE-TIME refusal,
and the compiler cannot generally know statically that a construction site's
allocator is an arena. The spec's answer for the analogous drop case is a check
at the point of construction, one branch per container — a runtime branch. This
likely becomes a runtime trap rather than a static guarantee.
Ownership tracking itself is untouched. Moves are still tracked; what is given
up is freeing one element individually, which is the point of an arena.
** 5. defenum autoincrement — DISPATCHED
C's rule: no value means previous+1, the first is 0, explicit and implicit mix.
Duplicates: an explicitly written one is an intended alias and is allowed. One
produced by autoincrement walking into a value another member holds is an
accident and is refused, naming both members.
** 6. JavaScript backend — HELD
wasm32 already works: test/wasm-run.mjs is a WASI host, the test table runs
wasm32 builds, web/index.html is in the tree. A second backend beside emit.ml
and x86.ml is the largest item here and the dev loop comes first.
** 7. defdata and defunion — QUEUED last
Today's [defunion] is already the tagged sum type. It is renamed [defdata],
and [defunion] becomes the C-style untagged one. Serves both FFI and type
punning, and cimport verifies it against the header where one exists —
cimport.ml:295 currently skips any record holding an anonymous union, leaving
the defstruct beside it unchecked.
Last, because the rename sweeps parse/check/emit/prelude/docs and every .flan
file, and would conflict with everything above.
* Open, found while working the list
** A transient signal -11 on the globals daemon
Seen once, in one of three consecutive test runs, by the agent doing the
C-x C-e work; the runs either side of it were clean. Not reproduced since —
three forced full runs (dune test --force) are green, 232 checks, 0 failures.
Worth remembering rather than chasing, because the daemon it appeared on is
one the move-only-global work (c124df3) changed: test_reload's fixture gained
a host global Vec and a run-time-new one. A teardown or a reload module that
defines rather than declares a global Vec would strand the block the live
process is using, which is exactly the shape a rare SIGSEGV takes. If it comes
back, start there.
* Status, end of 2026-09-17
Six of seven items are merged on dev-loop and green (dune build, dune test
--force, @x86, @page). One agent is still running: the arena work for item 4.
| item | what | state |
|------+------+-------|
| 1 | re-runnable main after the window closes | merged |
| 2 | C-x C-e installs a top-level form | merged |
| 3 | move-only globals, borrowed never moved | merged |
| 4 | edn dynamic value | merged (arena route) |
| 5 | defenum autoincrement | merged |
| 6 | javascript backend | first lane IN FLIGHT, 2026-09-17 evening |
| 7 | defdata rename + C-style defunion | merged |
Also merged, not from the list: macro-module symbol visibility, which
unblocked [flan dev --x86] in one process. sand.flan --x86 builds in 292ms
against 985ms on LLVM.
** Parked branches, kept deliberately
- worktree-agent-a18e9e62485eaedb5 — [drop] and recursive teardown. Finished
and green, not merged. See docs/handoffs/HANDOFF-drop.md, which is the part
worth keeping. Withdrawn because the refusal it answered is about teardown,
and an arena has none; see item 4 above.
** Open, carried forward
- A transient signal -11 on the globals daemon, seen once, not reproduced.
Recorded below.
- [drop]'s handoff flags that the [clone] / [get] / [map-next] refusals are
needed by the arena route too — they are about a copy of a header, which an
arena does not make safe — and that a Map has no operation answering *where*
a value lives, which is what reading an arena-parsed EDN document back would
need. Both were relayed to the arena agent.
- [Tast.Addr (Tast.Pfield ...)] on an Option: closed, and closed as
unreachable rather than fixed. Nothing in the source language builds it.
[.field] goes through [struct_target], which admits a struct and a pointer
to one and refuses everything else by name with a location — "(Option Point)
is not a struct, so it has no fields" — so [(addr (.x o))] never reaches a
place for [addr] to take. The node the drop lane hit was one the compiler
built for itself. Two rows in test_flan.ml pin the refusal, on the bare field
and on the address of one.
What is still asymmetric, and is a note rather than a bug: [x86.ml]'s
[field_loc] does lay out an Option's tag and value, and [emit.ml]'s [place]
admits only a named struct. Neither is reachable, so neither is tested, and
growing the LLVM side to match would be untestable code written to balance a
path nothing takes.
- Re-run still does not work under --two-process: a finished child is
genuinely gone. It now works under --x86 because --x86 runs merged.
- sand.flan still holds an uncommitted experiment line that is refused with a
message naming the fix: use (defvar the-data (Vec u8)) and fill it in a
function.
* Landed on dev-loop
Items 1, 2, 3 and 5 are merged and green (dune test --force, 232 elisp checks,
0 failures). Items 4 (drop) and 7 (defdata) are still being written. Item 6 —
classes and generic functions — is no longer held: it landed, and the M2 queue
above records it under item 6 with its commits.
** Re-run is merged, and does not work under --x86
Park and re-run live in the merged entry point's main(), and --x86 refuses the
merged daemon by design: a merged host exports every flan.* body for -rdynamic
and so interposes the prelude bodies of the LLVM-built macro module the
compiler loads into itself. --x86 therefore runs --two-process, where the
program is a child, and a child that finishes is genuinely Gone — there is
nothing to wake. [Program.rerun] answers with the two-process refusal rather
than the merged one, and a test pins it.
Re-run on x86 needs the merged daemon to accept --x86 first. Separate work.
** C-x C-e answers against a parked program
The complaint: (+ 1 1) at the top of a buffer was refused with "an expression
is evaluated at a frame boundary, and a parked program reaches none". True, and
about the wrong thing — the expression needs nothing from the program, and the
ones that do need globals the parked process is still holding.
The fix adds a second place a thunk can run rather than loosening what a place
has to be. [flan_merged_park] now waits on two flags: [program_asked] leaves
the park and runs main, and [program_poll] — set by [Program.wake], which
[eval_expr] calls after the delivery — drains the agent's ring and waits again.
The thread stays PROGRAM_PARKED throughout, so [:parked t] rides on the reply
that carries the value.
Why this is safe without a new concurrency model: while parked there is no
concurrency at all. The program's thread is asleep on a condvar, no frame is
executing, no global is being written — which is precisely what a frame
boundary provides. The break loop is the precedent, a thread servicing the same
ring while it is not running frames. Common Lisp answers the same question by
giving evaluation a thread of its own (SWANK's [thread-for-evaluation]) and
documents the resulting race as the programmer's problem; there is no race here
to document.
Merged-build only, and for the reason re-run is: [liveness_of] maps a finished
child to Gone under --two-process, so there is no parked thread to wake.
What became answerable with it: a thunk can now stop in the break loop on the
parked thread, so backtrace, break, restart, restart-at, abort, locals/inspect
and globals stop refusing on the state alone and refuse on [parked_break]
instead — a paused expression against the park would otherwise be unresumable.
Refused still, because their cause is not the park: nothing else.
** The headline complaint is verified fixed on sand.flan
Window opened, closed, the daemon reported parked, (:op "rerun") returned ok,
and xdotool found a live window from the second run.
The earlier claim that [flan dev sand.flan] failed with "unknown function
begin-drawing" was true only on the stale base the work started from, and was
retracted after a re-test. Nothing to chase.
* Evening of 2026-09-17 — the review, and four lanes off it
docs/REVIEW-production-readiness.md is the production-readiness review, written to be
implemented from. Item 4 (arena EDN) merged green before it was written, so the FIX list
proper is six of seven done and one in flight.
Four agent lanes are running off the review, each in its own worktree:
- Tier 1 + the runtime half of Tier 4: overflow guards, map removal (Odin's
backward-shift), the registry race, the scratch buffer, Addr-on-Option, the dev-runtime
aborts. One lane because they share runtime/*.c.
- Tier 3: clock, math, getenv, basic file ops. Appends to flan_rt.c in its own section so
the merge with the lane above stays clean.
- Tier 4 without the runtime: CLI error arms, flan run flags and -O, CI, the stale
DISCUSS.md x86 table, README's missing subcommands and env-var table.
- The JS backend's first slice, per docs/DISCUSS.md §5's settled decisions: #_ first, then
one function under node, then the corpus with a MATCH/DIFFER/REFUSED survey. New
reference clones for it are recorded in docs/REFERENCES.md ("Compiling to JavaScript").
Tier 2 (install and shipping) was explicitly passed over. Package visibility is skipped in
every lane — it needs a syntax decision from the author.
** All four lanes merged, end of 2026-09-17 evening
Tier 1 (runtime correctness + the runtime strays of Tier 4), Tier 3 (clock,
libm, getenv, file verbs), Tier 4 (CLI arms, flags, CI, docs) and the JS
dialect's first slice are all on dev-loop. Verified together: dune test
--force 232/0, @x86 116 MATCH / 0 DIFFER, @js 0 DIFFER, @sanitize clean.
Left for the author, recorded where each lives:
- Package visibility needs a syntax decision (review Tier 4 item 5).
- The v->gen word: spec-memory.md mandates it, nothing reads it; delete or
implement is a spec amendment (BUILT.md records the two options).
- sand.flan:167 still holds the refused defconst experiment; the diagnostic
now prints in full and names the fix.
- Tier 2 (install and shipping) deliberately not started.
* The repeal, 2026-09-18
The ownership flow analysis is removed: the per-function dead set, the borrow
flag, the loop-iteration diff, and the borrowed-never-moved rule for globals.
Use-after-move and double-free are no longer compile errors. What stands:
move-only as a type property (assignment hands over the header, clone is the
only copy), the struct/union/pool ownership rules, defconst-vs-defvar for
move-only globals, defer, all allocator capabilities, and the dev build's
generation checks — now the primary net, which is the Odin position the
memory design came from.
Decided after the bug hunt put four of its ten lanes inside this machinery.
An unsound checker is worse than none, because it is believed. The door back
is spec-memory.md's provenance pass: removal widened acceptance without
changing any accepted program's meaning, so a stricter pass can return
additively. spec-memory.md "The repeal" is the amendment; BUILT.md and
NEXT.md are annotated at their live claims.
Two of the day's fix lanes were cancelled with this (borrowed-flag, region
element); the while-condition fix merged in the morning is deleted again by
the repeal, and its pin with it.
* The second round, 2026-09-18 — Pool, move-only, and the gen word
Ordered by the author after the ownership repeal, on the same argument: the
Odin position, full stop.
- Pool and (Handle T) are gone — types, checker arms, runtime section,
fixtures. Two containers are enough; a slab with generational handles is a
library over a Vec when a program wants one.
- The move-only concept is gone: everything copies as its header, copyable?
left the predicate list (four remain), and the struct/defdata/defunion
owning-field refusals are lifted. The region rule stands untouched — a
container of owning elements is still built against a region and released
by one free-all.
- The gen word left both container headers (read by nothing since it was
written). A Vec is ptr len cap allocator epoch; the epoch trap stays.
- Container globals keep both declaration rules, reworded: they start zeroed
(a global initialiser is a compile-time constant, and a container's only
constant is the empty one), and a defconst container is refused since a
constant is not an assignable place. sand.flan's experiment line would now
be refused with the reworded sentence.
Everything verified together: dune test --force 232/0 with zero suite FAILs,
@x86 122 MATCH / 0 DIFFER, @sanitize clean, and the whole-repo check sweep
against the parent differs only where it should: the two negative fixtures
now accepted (vec-in-struct, the clause-less generics corpus), the two pool
fixtures deleted.
* End of 2026-09-18 — the hunt closed out
Ten bug lanes and three demolitions, all on dev-loop and verified together:
232 checks / 0 failures, @x86 122 MATCH / 0 DIFFER, @sanitize clean.
- Fixed: while-condition move (then repealed with the machinery), defenum i32
range, the Emacs client's framing/poll/point-min/quit bugs, the session
NULL-cell rollback, the stdout pipe drains, x86 shift masking, the reversed
slice traps in every build, NaN prints unsigned, the registry answers
honestly under churn, and reg at's stopped-only race.
- Removed by decision: ownership flow tracking, Pool and Handle, the
move-only concept, the v->gen word. spec-memory.md carries the repeal.
- Still recorded, not scheduled: trap paths that bypass the break loop,
parked orphans outliving dead daemons, emit.ml's transient test and
globals, map-grow's stale quote, x86's slice-from-ptr sentence, the
float->int UB divergence, the narrowed-buffer C-x C-e quirk, and the JS
backend's items (deprioritised).
* Late 2026-09-18 — the last two lanes
- Six trap paths park instead of killing the session — merged (e6d85c8).
All six park for inspection; transfer-fail and restart-unarmed refuse the
resume with the trap's sentence. New flan_trap_hook beside flan_break_hook,
whose contract could not carry these. Standalone builds die as before.
- Parked orphans exiting with their daemon — merged. PDEATHSIG on the
two-process child, armed in the agent, with a spawn-SIGKILL-reap test.
Found in passing: the eight orphans split 4/4 — four are MERGED daemons
whose editor vanished, a separate defect (accept_loop has no client
liveness), recorded here. The eight were killed by hand on 2026-09-18.
- That second defect is now fixed too. accept_loop keeps a grace since the
last client let go of the socket, and ends the session when it runs out.
The connection is per session and not per request — Emacs holds one
make-network-process for the whole of flan-dev and every deliberate
teardown sends [close] first — so an editor left open and idle is an editor
still attached, and the clock cannot run under it. Armed only after a first
client has connected, so a headless daemon waiting for one is untouched.
Two graces: 5 minutes parked, 30 minutes live, because a parked program is
invisible (which is why four piled up) and a live one is a window somebody
may be watching. FLAN_DEV_CLIENT_GRACE overrides in seconds; non-positive
turns it off. Six unit rows on the decision and one end-to-end daemon whose
client drops without a [close].
* The dynamic half of item 4, finished
Item 4 above is the complaint at line 23: the edn module should read into a
struct *and* answer a dynamic value when no type is given. The dynamic half is
now the package's rather than a test program's.
- [#{}] is read, not refused. The tokenizer's stated reason ("needs a hash set
to even represent") was a claim about a reader, and a tokenizer represents
nothing; [#{] pushes [}] on the same balance stack [{] does, one new token
kind, and [err-set] is gone rather than kept with a new message.
- [vendor/edn/read.flan] holds the [Value] data type and [(edn/read bytes)],
which answers an [(Option Value)] against the calling convention's
allocator. A set is [Value.Set] holding a deduplicated [(Vec Value)] —
[(Map Value bool)] does not typecheck, because [keyable] refuses a key
holding a Vec or a Map, and restricting set elements to keyable Values would
refuse [#{[0 0] [1 0]}], which is the file this was built for.
- A Value COPIES every string into the allocator; a Token stays a view. The
two layers diverge deliberately and both headers say so. A view handed out
of the function that owns the buffer is a dangling pointer no free-all would
even take back.
- Needed one compiler change to be possible at all: an imported [defdata] was
a refusal in load.ml ("not implemented yet, milestone 4"). It is a rename of
the type's name plus the [Type.Case] half of a constructor symbol; a match
pattern resolves its case against the scrutinee's type and never needed one.
Still not built, still item 9 on docs/PORTING.md's list: [(read-edn T bytes)],
the typed half. It wants a compile-time walk over a struct's fields and there
is no run-time type information to do it with at run time.
* Session close, 2026-09-20 — dynamic-first M1 landed
Merged on dev-loop, all green (dune test --force 0 failures, @x86 130 match,
@sanitize clean): the dyn type (unannotated defn params/returns are dyn,
NaN-boxed runtime, mark-sweep GC, --no-gc refuses residual dyn by location),
the provider macros (defedn/defjson off macro-slurp; NEXT.md item 9 closed),
computed global initialisers on both backends, x86 frame pushes (inspector
works under the x86 default), the !-suffix retirement, and the flan-dev→flan
rename. typed-flan branch freezes the static language pre-dyn.
** Still in flight, worktree branches to merge when they report
- x86 dyn lowering + the x86/LLVM invoke-restart divergence (one lane, two
commits) — the author is waiting on this one to start playing.
- The writable inspector (SLY-style set + editable render buffer).
- docs/SPIKE-DUPLICITY.md, the dyn/native boundary audit (report only).
** Open, author's call
- sand.flan holds uncommitted WIP: a defvar initialiser reading game-data.edn
at startup aborts the headless import (unhandled FileError at the test's
CWD). Options on the table: embed, handler-bind fallback, or harness dep.
- Signature pairing's cold-rebuild edge: a later type definition can silently
re-pair an unannotated parameter vector; a changed-pairing warning between
compiles was proposed and not yet queued.
* M2 queue, decided with the author 2026-09-20 — in order
1. dyn maps + keywords (interned, O(1) equality). Retires edn/Value after.
2. Per-type descriptors: dyn fields in structs/conditions become markable.
3. Typed containers into dyn as VIEWS — one descriptor word in the box,
reads box the element, writes tag-check. Rides on 2. No copies.
DECIDED 2026-09-19: the descriptor is its own thing, not the slice type
reused. Two reasons. A dyn value is a single word and a slice is two, so
reusing the slice buys no allocation back — the descriptor goes on the
heap either way. And a slice carries where and how many but not of what,
which is the one fact dyn needs, since boxing a read and tag-checking a
write both require the element type. The descriptor is therefore pointer,
length, and element type: a slice plus the piece a slice is missing.
Left open until the lane is built: whether the descriptor points at the
container or is a fattened slice stored beside it. That only bites if the
container can grow and move, which would leave a push through dyn holding
a stale pointer. — LANDED. Settled: a Vec view holds the address of the
Vec's own header and reads its ptr/len live on every operation, so a push
that reallocates cannot go stale — there is no snapshot to invalidate,
because flan_vec_grow overwrites that same header in place. A slice and a
fixed array cannot grow, so a flat view snapshots pointer and length once,
which is sound for both and is not the weaker half of an asymmetric
choice — pointing a flat view at its own value's slot instead would be
worse, since a slot's lifetime is not the slice's. The element set is i64,
f64 and bool only: a string element's dyn form is a pointer into the
collector's heap, and a typed container's storage is memory the collector
never scans, so a wider set would let a write plant a live reference
nothing traces. (Vec string) and (Map K V) keep the refusal [box] already
gave every container. Both backends, runtime/flan_dyn.c and .h, checker
tests, an acceptance row per backend, and a survey program
(dyn-view.flan) proving the view against both a growing Vec and a fixed
array/slice, plus its own two trap modes.
REVIEW, 2026-09-20: relocation was proved sound but relocation was not
the hazard that mattered — a view can outlive the frame its Vec header
sits in, which nothing could reach before this lane because [box]
refused every container outright. Three routes, all newly constructible,
all stack-use-after-return: returning a view, stashing one in a dyn
global, leaving one behind across a condition transfer. AUTHOR'S RULE:
on the dynamic side Flan aims where Clojure and Common Lisp are — holding
a value should not hand you garbage — so a container may cross into dyn as
a view only when its own storage is permanent — a global's.
[permanent_root] in check.ml decides it: a global, a field of one, an
element of a permanent ARRAY (an element of a slice is NOT — a slice holds
only ptr+len, and what they point at can be a frame already gone; the [At]
arm steps every index of a multi-index [(at g i j)] the way [indexed] does
and demands an array at each level, because the whole index list rides on
one node and reading the target's type alone settled level zero only), or
a slice cut directly from one at the call (the trace is lost the moment
it is bound to a name first). Everything else — a local, a parameter, a
temporary, anything behind a (Ptr T) — is refused by name, pointing at
the defvar spelling that works. A heap-held header is not expressible
soundly at this milestone for a structural reason rather than a missing
feature: a (Ptr (Vec i64)) taken off a heap block and one taken off a
local are the same type, so admitting a Ptr as permanent would readmit
the exact hole this closes.
The rule is a narrowing, not a proof, and flan_dyn.h states the property
that actually holds: a view is exactly as stale-safe as the thing it is a
view of, no more and no less. A global [i64] whose data was cut from a
frame that has since returned still passes [permanent_root] and still
reads a dead frame. What the guard closes is the routes the checker can
see, not every route.
An arena-held header is not a separate case for [permanent_root] — an
arena changes where a Vec's elements live, never where its own header
(the binding) lives, so the cases above already decide it — but that is
coverage of the HEADER's lifetime only, and releasing the arena under a
live view is a separate hazard handled at RUN time, not here.
[view_vec_check] in flan_dyn.c is what handles it: a Vec records its
allocator's epoch and every view operation re-checks it, so (free-all ar)
with a live view over an arena-grown global Vec traps cleanly and by name
at the next read — verified. (arena-destroy ar) is the gap: it frees the
allocator block itself, so the epoch [view_vec_check] goes to read is
freed memory. Run plainly it happens to trap anyway — the freed block
still held the bumped epoch — but that is the allocator not having reused
it yet, not a check that held; under ASan the same program is a
heap-use-after-free in [view_vec_check] before it decides anything. Left
standing rather than fixed with this lane: the typed side has it
identically in [flan_vec_check], flan_rt.c, which reads the same freed
allocator's epoch, so it is a repo-level question about arena-destroy's
ordering and not about views.
Three more, all in the runtime rather than the boundary: [view_vec_check]
recursed into itself rendering the very view it had just declared unsafe
to read (fixed by never rendering it — the sentence names the epochs and
nothing else); [dyn_equal]'s VEC arm read raw [len]/[items] regardless of
kind, so two views with different contents compared equal and a map keyed
by a view collided with every other view (fixed with view-aware
length/element readers, [vecish_len]/[vecish_at]); and the three
restatements of flan_vec's layout (flan_rt.c, flan_dyn.c, dyn_ops.c) had
nothing tying them together despite a comment's claim that they did — a
[layout] probe on each, compared field by field in dyn_ops.c's new
"layout" mode, makes a disagreement a FAIL line instead of a silent
corruption.
4. nil: arrives with maps. nil <-> None at (Option T) boundaries, trap at
bare T, (Some nil) unconstructible. — LANDED, 3c1fb1b. The bare-T trap is
split: a literal nil the checker can see is refused at compile time, in
expect itself; a dyn only known nil at run time still reaches
flan_dyn_need_i64's existing trap unchanged. (Option (Option T)) does not
cross either direction, same ambiguity as (Some nil). (Option dyn) is a
legal type the boundary code already treats correctly — the payload is
the identity, box and unbox both — but not yet a storable value anywhere:
the per-type-descriptor pass (item 2) refuses it the way it refuses (Vec
dyn), and item 4 does not lift that gate.
5. Typed = and != grow strings: bytewise, length + same-pointer fast paths,
both backends, one survey program. Ordering stays refused. — LANDED, daed039
6. defclass = named dyn map + shape tag; CLOS class dispatch AND
Clojure-style arbitrary dispatch functions. After 1. — LANDED, 8d2bf2a
(the feature), 5af990e (the daemon proof and an x86 descriptor fix it
turned up) and 6c6024e. Written up below, "Classes and generic
functions, 2026-09-20".
7. dyn if: truthiness (nil/false are false, all else true). Typed stays
strict bool. — LANDED, 264765a
Reaches when, cond, if's own condition, and's condition, or's
condition, not and while for free or by hand, all through one funnel
in check.ml (check_truthy). Two things fell out of it that nobody had
decided going in, one fixed on review and one left as the author's
call:
- Neither and nor or handed back the operand that decided it.
Clojure's rule is that both do; each answered a bare bool sentinel
on its deciding path instead. and's "false" sat in the else arm, so
check_if typed the real branch first and boxed the sentinel to
match: an all-truthy and did carry its last dyn operand through,
but a falsey one answered false where Clojure answers the falsey
operand — (and (box 1) (box nil) x) printed false, not nil. or's
"true" sat in the then arm, the one check_if types first, so the
sentinel decided the whole expression's type and a later non-bool
dyn answer hit the strict bool boundary and trapped: (or nil "x"),
the canonical (or x default) idiom, crashed rather than answering
"x". FIXED for or in ad0f1fb and for and in this pass: both now
bind the test to a temp and answer the temp on the deciding path,
Clojure's own expansion — (let [t a] (if t t b)) for or and (let [t
a] (if t b t)) for and — evaluating each test exactly once. The
asymmetry between the two forms is fully closed; the survey program
(test/programs/dyn-if-truthy.flan) pins both, short-circuit and
single-evaluation included, and test_flan.ml pins both desugarings
down to the bound name and the bound value.
Two things came with that pass. The temp binding and the if it
feeds now carry the *operand's* loc rather than the whole form's,
which ad0f1fb had lost for or: (or (vec-new i32) v) blamed the
enclosing form at 3:13 and now points at the operand at 3:18, and
and's second operand gained the same precision. And, noted and not
acted on: with both arms of the desugared if now holding real
values, a dyn operand mixed with a typed bool one makes check_if
unify them by the then arm, so a non-bool dyn value on the losing
side traps at the strict bool boundary — (or false (box "s")) and
(and (box nil) some-bool) both do. Each form used to be safe in
exactly one of those directions, because the sentinel it answered
was a bool literal that boxed to fit the real branch; neither is
now, and they are at least symmetric about it. (and (box nil)
some-bool) printing false is the one previously-compiling behaviour
this pass changed. Making a bool arm and a dyn arm join as dyn is a
check_if question and the author's call, not settled here.
- A bare keyword condition used to be checked with want:Bool from the
start and refused by the keyword arm's enum-or-refuse case: ":kw is
an enum member where an enum is expected and a dyn keyword
elsewhere, but bool is expected here", there being no enum in play.
Checked with no expectation first, as every scrutinee now is, it
resolves as the dyn keyword instead, and a dyn keyword is
unconditionally truthy — a typed if with a bare keyword condition
now compiles and always takes the then branch. The author's call:
lispy truthiness wins here, the lost diagnostic is not brought back.
Pinned in test_flan.ml so it does not regress by accident.
Also noted at check_truthy (check.ml) and not acted on: check_truthy's
own retry-on-failure, needed to keep a refused literal's or None's
message unchanged, re-runs the whole failing subtree rather than only
the leaf that needs it, which is exponential in how deep a chain of
nested not gets on a program that does not type-check. Moot for
anything that compiles; visible only around twenty levels deep, and
only the dev daemon's half-typed-form recompiles could ever feel it.
A cheaper retry was tried and shelved — it would need to thread want
exactly as far as the full retry already does, or it changes which
literal further inside a compound condition gets the nicer message,
not just the speed.
8. Return slot stays mandatory (dyn or ()) — the parse ambiguity it closes
is real; revisit only if it grates. SETTLED 2026-09-19, reconfirmed with
the author: both spellings stay legal, () is not collapsing into dyn.
No work follows from this one.
All of it dispatches after the x86-dyn lane lands. The struct dyn-field
refusal (01e60fa) is the stopgap 2 lifts.
** The two models, named 2026-09-19
With a collector in the runtime, the direction has a shorter statement than
it used to. The dynamic paths mimic Clojure. The static paths mimic Odin.
Both carry a little more ML than either of them does.
That is a tiebreaker, not a slogan. A question on the dyn side that Clojure
has already answered takes Clojure's answer unless there is a reason to
depart, and the same holds for Odin on the static side. Keywords, maps and
nil landed under that reasoning without it being written down yet.
Common Lisp is consulted alongside Clojure on the dynamic side, and on some
questions it is the better authority of the two. The condition system is the
standing proof: handler-bind, the restarts and invoke-restart are Common
Lisp, and Clojure has nothing resembling them. handler-case is the same
lineage — Clojure's try/catch is the shape most reached for, but the form
being added is Common Lisp's, and it is named for the Lisp rather than the
Clojure because it is the unwinding half of a pair whose other half is
already CL's.
Where the two disagree, the question is which one the rest of Flan already
agrees with. Conditions say Common Lisp. Maps, keywords and nil say Clojure.
Neither answer generalises to the other's territory.
The ML share is the part neither model supplies — the type system, the
options, the exhaustive matching, and whatever a second ML surface would
eventually add if the deferred syntax question ever reopens.
** Arithmetic semantics do not fork across the two spaces, decided 2026-09-20
One operator, one meaning, both sides. `/` on integers truncates toward zero
and `%` is its remainder, sign following the dividend — LLVM's sdiv/srem, the
x86 backend's cqo/idiv, and flan_dyn.c's arith all already agree, and that
agreement is now the rule rather than a coincidence. The author's call: this
sort of semantics is normalized across the dynamic and static spaces, so the
Clojure tiebreaker above does not reach it. Clojure's flooring `mod` (sign of
the divisor) is NOT to be added as a dyn-side-only behavior of `%`; if a
flooring mod is ever wanted it is a second, separately named operation
available to both spaces, the way Common Lisp keeps `rem` and `mod` side by
side. Division by zero and INT64_MIN / -1 trap identically on both sides,
and float `%` is fmod on both backends and in dyn.
** handler-case, decided 2026-09-19
Flan has handler-bind, which is the resuming handler: it runs where the
condition was signalled, with the stack still standing, and carries on by
invoking a restart. What it has no spelling for is the other half — unwind,
and answer the whole form with a value. Clojure spells that try/catch and
reaches for it constantly; the closest thing here is a handler-bind plus a
use-value dance that is far heavier than the intent, or a pre-check that
races the read it guards.
The gap showed itself when edn/read-file stopped returning an Option. The
caller that used to write or-else against a None had nothing left to write,
because the missing file now arrives as a FileError condition and the only
concise way to answer a condition with a default did not exist. The shape
wanted is:
(handler-case (edn/read-file "game-data.edn")
[(FileError [c] nil)])
which keeps read-file's decision intact — the caller still says what a
missing file means — while costing one form instead of a machine. Until it
lands, sand.flan guards the read with file-exists?, which is a stopgap and
racy, and should be rewritten the moment this exists.
** The JS backend answers string equality wrongly, parked 2026-09-19
Typed = and != grew strings in daed039, and the JS dialect was not taught the
case. A string there is a view object and the arm at lib/js.ml:856 compares
with ===, which asks whether two views are the same object rather than
whether their bytes agree. The arm was unreachable for strings until the
checker stopped refusing them, so the lane made an existing hole live without
touching the file. Equal literals still answer true, because equal literals
intern to one view, which is what makes the wrong answer quiet rather than
obvious: (= s (string (slice (bytes s) 0 3))) is true natively and false
under --target=js.
The author parked it. JS stays deprioritised and the fix is not queued. The
option on the table when it is picked up again is a loud refusal in that arm
rather than a real implementation, so the dialect says it cannot do this
instead of saying something false.
** Sweep policy, decided 2026-09-19
A lane runs the fast check and nothing more. `dune test` is the whole of a
lane's obligation. It used to be judged by reading the printed output rather
than by trusting the exit status, on the theory that some path through the
acceptance runner could print a FAIL and still exit 0. That theory did not
hold up: test_acceptance.ml is one match on whether clang is on PATH, the
wasmtime/raylib/lldb probes inside it are ordinary `if`s that fall through to
the same tail rather than branches that leave early, and the tail already
turned a nonzero failure count into exit 1 — so did every other test binary's
tail, checked the same way. test_acceptance.ml now also carries an `at_exit`
guard, but it closes no open gap; it is insurance against a future case
leaving past the tail instead of through it. The exit status was already
trustworthy and stays that way, so either check does. Running one program
directly to capture its real output for an acceptance row is still expected;
that is cheap. What a lane may no longer do is sweep.
The x86 survey and the sanitizer sweep run once, after several lanes have
landed, and whatever they turn up is dispatched as fixes in a single batch.
The reason is arithmetic: a survey walks all 156 programs across three modes,
and a lane that touches a handful of them was paying that cost in full to
learn nothing about the rest. Paid once for several lanes, the same sweep
answers the same question at a fraction of the wall clock. The consequence to
accept is that a lane is reviewed on its code rather than on sweep numbers it
no longer produces, which is what the review before a merge is for.
* handler-case, decided 2026-09-19
Built, both backends, and it needed no backend work at all: it is a
handler-bind whose clause invokes a restart the form established around
itself, which is spec-conditions.md's one open question about the operator
answered in the affirmative. The shape is (handler-case BODY [(T [c] ...)]),
body first and clauses after, the opposite of handler-bind's order because a
handler-bind reads as something put around a body and this one reads as a body
with answers hung off it.
Everything the unwinding form needs it inherits. Defers and the
with-allocator restore run on the way out because a transfer already runs them
for every frame it leaves. The body and every clause agree on one type because
§3 already says a restart-case's do, and a clause that disagrees is refused
with the same message an if with disagreeing arms gets. A condition no clause
lists installs no matching frame and carries on outward untouched. A clause
runs at the form, so it sees the establishing function's locals, which a
handler-bind clause cannot — that is the whole difference, and it falls out of
where a restart clause runs rather than being arranged for.
The one wart, noted and left: the restart the form makes up for itself is on
the restart stack like any other, so a break loop entered underneath one lists
it. Choosing it there is refused loudly rather than answered wrongly, and
hiding it would mean a new field in a frame layout written out in emit.ml, in
x86.ml and in flan_rt.c.
* Surface syntax discussion, 2026-09-19
The author wants an F#-ish indentation-based ML surface living side by side
with s-expressions, not replacing them. The languages that disappear for the
author, in the order named: Python first, then Odin, then F#. That ordering
is the case for why Flan's own parens might be costing more than they look
like they cost.
The architecture agreed if it is ever built: one AST, the existing forms
unchanged, and a second reader in front of it. Macros stay usable from
either surface, since they operate on the same AST either way. A Nim-style
quote-block was floated as the way a macro's own body could be written in
the ML syntax rather than in s-expressions, without needing a third
representation.
Middle options came up and were set aside rather than chosen. Parinfer stays
an editor trick — it never changes the language, only how parens are typed,
so it does not touch the actual complaint. Wisp and sweet-expressions
(indentation implying the parens) were considered and are closer to a real
second surface than Parinfer, but still read as a compromise rather than the
ML syntax the author actually wants. A simplified in-paren syntax was also
on the table and rejected on the same grounds — it thins the parens without
removing them. Rhombus was named as the maximal reference point: whatever a
full second surface costs, Rhombus is roughly what it costs to do properly.
Decided: deferred, no spike queued. The author's working hypothesis is that
the friction with Clojure may not be the parens at all — it may be
immutability, and the discipline of planning a shape ahead of time that
comes with it. The plan is to write imperative Flan as it stands and see
whether the parens still grate once that variable is gone. Revisit this once
that evidence exists.
* The x86 backend tracks LLVM -O0, decided 2026-09-20
The ruling, in the author's words: the x86 backend must behave as closely to
LLVM at -O0 as possible. A construct LLVM compiles, x86 compiles, and the two
must agree on what the program observably does. The backend is allowed to
refuse a node it does not lower — that is what X86.Unsupported is for and it
is how the survey reports a gap — but a refusal is a bug to be closed, not a
position. "LLVM takes this and x86 does not" is by itself a defect report.
What made it a ruling was typed float %. emit.ml's prim arm emits frem for Rem
on a float, so (% 7.5 2) compiled under LLVM and printed 1.5; the matching arm
in x86.ml had no float Rem case and died at build time with an unlocated
internal error, "x86: that operator on f64". Dyn % on floats worked on both backends the whole time
— flan_dyn.c's arith implements the fmod identity — so deleting the
annotations made the program build again, which is exactly backwards. x86 is
the dev loop's default backend, which is what turned a backend gap into a
thing the author hit while writing ordinary code.
Fixed by calling the same function LLVM calls. There is no SSE remainder
instruction and LLVM does not invent one: a frem that reaches the code
generator becomes a call to fmod or fmodf, which objdump shows as a call to
the PLT stub — fourteen of them in a build of the probe whose operands come
through globals, and none at all in one written with float literals, where the
pair is folded to its answer before any call exists. x86.ml now loads the two operands into xmm0 and
xmm1 — already the SysV argument registers — and calls fmod or fmodf by width.
Agreement is then by construction rather than by a second hand-written
identity that would have to get every rounding, every signed zero and every
infinity right on its own. Nothing new had to be arranged for the link: the
prelude already declares both symbols as fmod-f32 and fmod-f64, and every
link passes -lm.
Rem was the only gap. Walking emit.ml's prim arm against x86.ml's: the whole
float surface is Add, Sub, Mul, Div, Rem and the six comparisons. x86 had four
of the five arithmetic operators and all six comparisons, and the comparisons
match LLVM's ordered predicates — oeq and one are built there from a setcc
against ucomis plus the setnp that rules out the unordered case, which is what
the o in the LLVM predicate means. The bitwise and shift arms are integer-only
on both sides. So nothing else was missing.
** The aspiration: tests that say x86 still tracks -O0
Wanted, and half of it exists. @x86 (test/dune:239) is already the diff: it
builds every program in test/programs, spike/x86 and spike/js twice — once
through LLVM, once through --x86 — runs both, and compares stdout, stderr and
the exit status. SURVEY_STRICT makes a DIFFER or a by-name refusal a failing
build. So a corpus program that exercises a construct is already a test that
the two backends agree about it, and the float % cases added to math3.flan are
in that set by being in test/programs.
What @x86 does not do is pin the LLVM side at -O0. It builds both sides at the
default -O2, so a construct LLVM folds at compile time — a % over two float
literals is one: that build contains no fmod call — is compared as a constant
against the x86 backend's actual lowering. The float % block in math3.flan
goes through globals for that reason, the same reason arith.flan gives for its
own. Two things would close the rest of the gap: an -O0 pass of the sweep, so
the LLVM side emits the calls and branches rather than the answers — the
script already has SURVEY_FLAGS, which hands the same extra flags to both
sides, and both sides do accept -O0 — and something that walks the two prim
match arms mechanically rather than relying on somebody reading them side by
side, which is how this gap survived. Neither is queued.
* Per-form initialisation semantics on re-run, decided 2026-09-20
The defining form is the contract, and the daemon does not have a policy about
globals at all.
- [defvar] is Common Lisp's [defvar]: its initialiser runs only if the variable
is not already initialised. Its value therefore survives a re-run, which is
what the daemon has always promised in its own words — "the globals are as
the last run left them" — and what a zeroed one already got for free, since
.bss is untouched by a second entry into main.
- [defconst] with a compile-time-constant initialiser is written into the
image — the linker's on one backend, [flan..init-data]'s stores on the
other — and no startup code reaches it, so a re-run reaches neither. The
split is [Tast.const_init]'s and it is over the *initialiser*, not over the
form: a computed [defconst] would be guarded exactly like a computed
[defvar]. The x86 backend does guard one; the LLVM backend refuses the
program instead, because [Emit.const] has nowhere to run a computed value.
That divergence predates the re-run rule — the refusal landed in 495629f and
the flags in 931cf86 — and is noted here rather than fixed.
Resolved 2026-09-20: there is no computed [defconst] any more, so the
paragraph above describes a program the checker no longer accepts. See "A
defconst is a compiler const" below.
- If the language grows a [def]-style form that re-evaluates, that form
recomputes on every run. None exists today and none was invented for this;
the rule is written so that adding one is a new case and not a revision.
A re-run may therefore re-enter the startup function as freely as it re-enters
anything else. Each initialiser guards itself: [Emit.startup_plan] gives every
computed global a flag of its own — zeroed in .bss, set after the store — and
wraps the store in a test of it. Per global rather than per startup function,
because the rule belongs to the form; dev builds only, so a release build's
.ll and .s are byte for byte what they were, which was measured on both
backends rather than argued.
The flag's name is [.init~once.<global>]. It was [.init-once.<global>] until
2026-09-20, which a program could collide with: [.] and [-] are both ordinary
symbol constituents, so [(defvar .init-once.x i64 7)] beside a computed [x]
emitted the same symbol twice and the dev build died at the assembler on both
backends — and worse, the flag's Bool was registered over the user's global in
[Emit.globals], so the store to it came out as an [i1]. [~] is a terminator in
the reader, so no symbol a program can write contains one; [destructure~N]
uses the same trick. [test/programs/dev-rerun.flan] carries a global named
[.init-once.counter] to keep it pinned.
Verified against a live daemon on both backends with
[test/programs/dev-rerun.flan]: a computed i64 counts 41, 42, 43, 44 across
four runs where it counted 41, 41, 41, 41 before; a computed dyn map keeps the
mutations every run made to it; a zeroed [defvar] still accumulates; a
[defconst] is untouched. The block in test_dev.ml that pins it fails on the
pre-fix compiler in exactly the two computed cases and in neither of the other
two, which is the other half of the claim.
** The interaction with the park's root reset
Written when the watermark fix had not landed; both are merged now, so what
holds is this. The park's [flan_dyn_root_reset] preserves the dyn globals'
permanent roots — it cuts the stack back to [roots_base], the watermark
[flan_dyn_root_globals_end] recorded. A re-run's [flan_dyn_root_globals_begin]
empties the stack outright, the emitted main re-pushes every global's root,
and [_end] re-records the base, so push-exactly-once holds via the bracket
rather than via anything the guarded startup does. The guarded startup
skipping its stores on a re-run is safe against all of that because the
pushes take the global's slot address, never its value, and they sit before
the startup call on both backends — nothing in the bracket depends on an
initialiser having run.
** bin/main.ml still spells the compile pipeline out by hand
The test directory's copies of Load → Check → Reach.link now go through
[Test_support.linked] (test/test_support.ml). bin/main.ml has the same shape
twice more — :684 and :864, each a load, a check and a [Reach.link] feeding
[Build.executable] — and they were left alone, because the lane that did this
was test/-scoped and because they are not quite the same three calls: the CLI
loads through its own [load] and checks with [Check.program_all] rather than
[Check.program]. So closing this is not a matter of calling the test module
from bin/, which would be backwards anyway; it means the pipeline moving into
lib/ — Build, or a small front-end module beside it — with the two checkers'
difference made an argument, and bin/ and test_support.ml both calling that.
Not queued.
* Memory diagnostics on demand, decided 2026-09-20
** The author's spec
Clojure's [*warn-on-boxed*] crossed with Rider's heap-allocation squiggles.
Both kinds of allocation: the GC's — boxing a typed value into dyn where it is
not an immediate, map/vec/string construction, the big-int spill — and the
native side's — vec-new, a push that may grow, arena allocation, slurp,
anything routing through an allocator. Two visually distinct classes, rendered
in different colours by the editor and both FAINTER than an error ("they
should somewhat fade"). Off by default, surfaced on demand two ways: an Emacs
command of the "check for warnings" shape that asks for the current buffer's
and overlays the answer, and a compiler flag so the CLI can decide when they
appear. Flycheck integration a nice-to-have. Precision over completeness in
v1: never mark a site that does not allocate — a dyn immediate must not
squiggle — and a site that allocates only sometimes says "may allocate".
** What landed
[Check.memory_sites], a pass over the finished program in the shape
[Check.no_gc] already has: it runs after checking, answers a [Loc.diag list],
and nothing downstream is told it exists. Asking cannot change what compiles.
The class rides on the diagnostic's [kind] — "memory/gc" or "memory/native" —
so the CLI and the daemon dispatch on one field and neither parses a message.
[flan check FILE --warn-memory] and [flan build ... --warn-memory] print them
to stderr in the standard [file:line:col: warning: ] shape with the squiggle,
filtered to the file named on the command line. The exit status does not move.
[(:op "memory")] on the dev daemon answers [(LOC KIND MESSAGE)] rows over
[t.session.program], needing no running program — a parked session answers it.
[M-x flan-check-memory] in flan.el paints them: two faces, both fainter than
[flan-error-face] and with no message drawn beside the line, priority under an
error's so a refusal still wins a shared span. [M-x flan-clear-memory], or an
edit, or asking again, takes them down.
** Where this overrode the spec, and the evidence
*** (vec-new T) and (map-new K V) are not marked.
The spec's enumeration lists vec-new as a native allocation; the runtime says
otherwise and the spec's own precision rule says to believe the runtime. The
["vec-new"] arm in check.ml passes a capacity of literal zero to
[flan_vec_init], and that function's body returns before [flan_vec_grow] when
[cap <= 0]. [flan_map_init] never takes a block at all and carries its own
comment saying so — "No block until something is put in it". The block arrives
at the first push or put, and those are the lines marked. [(vec-new dyn)] and
a dyn map literal are the other answer: those are the dyn runtime's own
objects and [gc_alloc] runs at the call, so they are marked.
The classifier reads [flan_vec_init]'s capacity argument rather than keying on
the symbol, which is what lets [slurp] — the caller that sizes the Vec to the
file — be marked "allocates" through the same entry point that vec-new is
silent through.
*** Dyn arithmetic is not marked.
[flan_dyn_add] and its siblings end in [flan_dyn_from_i64], so a wide enough
result does spill. Nothing static knows the operands, and a squiggle under
every dyn [+] is exactly the false positive the precision rule exists to
prevent. [flan_dyn_from_i64] IS marked at an explicit crossing, and only when
the value can leave the 48-bit payload: a literal inside ±2^47 and a value
widened from a narrower integer type are both provably immediate and silent.
*** Keywords are not marked.
Interned and immortal — flan_dyn.c's intern table holds the only copy of each
name, nothing removes one, and [mark_value] walks BOX_OBJ and nothing else.
There is no GC object to attribute.
*** Dyn push and put are not marked.
Added 2026-09-20, from a review: it had only ever been in a test comment.
[flan_dyn_push] and [flan_dyn_map_set] are not in [Check.memory_class]'s
table and they provably may allocate — a dyn vector or map growing itself is
[gc_alloc] on the collector's heap, the same class every other gc row names.
This is the one row the precision rule does not decide; it is a judgement.
The unit this pass reports is a line the programmer can act on — crossing
into dyn is a choice, pushing onto an allocator's Vec is a choice — and a dyn
container taking a block to hold what was just put in it is the only thing it
could do. Marking it would squiggle every =(push dv x)= in a program that
chose dyn, which is the noise the rule exists to keep out. Pinned as a
negative in test_flan.ml's "collected heap" row, beside the typed push that
IS marked on the line above it.
The consequence is that the daemon's =:note= cannot claim "every site the
checker can prove may allocate", and no longer does: lib/dev.ml's memory op
says what holds and names this exception.
*** The overlays outlive the next command.
The spec asked for the paths that clear an error overlay. Those hang off
[pre-command-hook], which takes an overlay down before the next keystroke —
right for feedback about a failed evaluation, and fatal for an annotation:
moving point through a marked line is what you do with these on screen. They
clear on [after-change-functions] instead, plus the explicit command and the
repeat-toggle. Documented in emacs/MANUAL.md.
** Flycheck
flan.el has no flycheck wiring of any kind, so per the spec's own branch no
checker was defined. emacs/MANUAL.md documents the CLI pattern and carries the
[flycheck-define-checker] form for anyone who wants one — the flag is the
command, and the printed shape is the error pattern.
** Pinned
test/test_flan.ml pins three programs by exact location, kind and message: the
collected heap (a dyn vec, a map literal, a string crossing, a wide i64, and
the five immediates plus an i32 widening that must stay silent); the allocator
side (arena-new, slurp's sized vec-init, a typed container's view record, put,
reserve, with a typed vec-new and map-new silent between them); and dyn
arithmetic answering nothing at all. test/test_dev.ml drives [(:op "memory")]
over the socket against programs/dev-dyn-global.flan, whose one line is two
gc crossings at two columns and no native allocation anywhere.
* Review-batch findings, 2026-09-20
Two things found in review that this lane could not fix in the files it owned.
Written down here so they are not lost with the branch.
** The daemon leaves its temp directory behind, forever
Every session makes =/tmp/flan-dev-<pid>/= — lib/dev.ml:3516 for the
two-process daemon and lib/dev.ml:4415 for the merged one — and nothing ever
removes it. It holds the built =program=, the host's =host.ll= or =host.s=,
the reload modules and =agent.sock=: about 7MB a session. The review counted
873 of them, 5GB, on the morning of 2026-09-19; this lane counted 68 and 457MB
on 2026-09-20. Whatever removed the difference, nothing in the tree did, and
the count climbs again with every =M-x flan=.
The fix is small and the merged daemon already has the one place for it. Its
session ends at lib/dev.ml:4395: [accept_loop] returns, the listening socket
closes, the editor socket is unlinked, and [Unix._exit 0] follows. A recursive
remove of [dir] belongs between the unlink and the flush — that one site
covers all three ways a session ends cleanly, because all three come back
through [accept_loop]:
- =close= from the editor (lib/dev.ml:3359, which returns [true] and ends the
loop);
- no editor connected for the grace period (lib/dev.ml:3472);
- the program finished and the parked process is let go.
The two-process daemon needs the same thing at its own session end.
Two deliberate non-goals, and they are the reason this is worth spelling out
rather than just doing:
- *Not on a crash.* The sibling branch at lib/dev.ml:4393 is [accept_loop]
raising, and the directory is the post-mortem — the binary and the exact IR
it was built from. Only the clean return cleans up.
- *Not other sessions' directories.* A sweep of =/tmp/flan-dev-*= would delete
the working directory of a daemon that is still running, and a stale pid is
not proof of anything. Each session removes its own and no more.
Not done here because lib/dev.ml belongs to another lane that has not merged.
** and's last operand gets a misdirected caret in a want-free position
[shortcircuit] in lib/parse.ml documents this at the site; the summary is that
=(println (and true true (vec-new i32)))= reports "expected (Vec i32), found
bool" with the caret on the second =true=. The last operand of an =and= is the
then arm, check_if types the then arm first, and the mismatch is therefore
reported against the else arm, which carries the *previous* operand's loc.
Every other operand position is right, because an operand anywhere but last is
a condition and check_truthy blames it at its own loc; =or= is right
everywhere, because there the chain and not the sentinel sits in the else arm.
Compiled on the tree at every operand position of both forms, want-free and
want-ful; want-ful is right everywhere too, because the want reaches each arm
instead of the arms being unified against each other.
Not fixed. Three candidate fixes were considered and rejected: giving the else
arm the last operand's loc makes the sentence read backwards ("expected (Vec
i32)" under a caret on the thing that is the (Vec i32)); answering a bool
sentinel again reverts the fix that made =(or nil "x")= answer ="x"=; and
inverting the condition to move the last operand into the else arm costs a
[not] per operand and worse locs than it buys. What would fix it is check_if
preferring the arm that is not a compiler temp when it decides which one to
blame — a change in check.ml, which this lane did not own.
* A numeric cast opens a dyn box, decided 2026-09-20
Every numeric cast — =(f64 x)=, =(i64 x)=, =(u32 x)=, =(f32 x)=, all of
them — takes a dyn operand now. Until this, the cast arm refused it with "f64
converts a number, found dyn", and the only thing in the language that opened
a box was a typed parameter, so a program wanting a number out of a dyn wrote
a one-line function whose parameter slot did the unboxing and called *that*.
A cast is the operator for "convert this to that"; it is the spelling that
should have worked.
Three cases, and the middle one is the author's call.
1. Same kind. The box holds what the cast asks for, so the cast is the unbox
and nothing else. A dyn box only ever holds an i64, an f64 or a bool among
the numbers, so =(f32 d)= on a float box unboxes to f64 and narrows, and
=(u32 d)= on an int box unboxes to i64 and narrows — each by the rule the
same cast already follows on a typed operand.
2. Cross kind — COERCE, with a warning. The author's words were "just coerce
it with a warning". =(f64 int-box)= is 7 -> 7.0 and =(i64 float-box)= is
2.5 -> 2, the truncation toward zero =(i64 2.5)= already does, range-check
and ArithError included. This overrides the tempting rule of matching the
parameter boundary, which traps on a kind mismatch: a cast is already a
conversion operator — =(f64 5)= converts a typed integer — so converting
across the box is the cast doing its job. The warning exists because the
box's kind was not what the program apparently expected, not because the
conversion is in doubt.
3. A box holding a non-number traps: text, nil, keyword, vec, map — and
*bool*, which is not a special case but the parameter boundary's existing
answer mirrored. flan_dyn_need_i64 refuses a dyn holding true at a typed
i64 parameter today, and =(i64 d)= refuses it for the same reason and in
the same voice.
** The warning is once per SITE
These casts sit in per-cell-per-frame loops — sand.flan runs at 120fps — so a
per-occurrence line is a flood and not a diagnostic. check.ml threads the
site's loc text into the runtime call and flan_dyn.c keeps a small table of
sites it has already spoken about, keyed on the loc's *bytes* rather than its
address: the two backends emit their own constants for it and neither promises
that two mentions of one site share a pointer. Sixty-four sites, and past that
it stops deduplicating rather than stops warning — the noisy failure, not the
silent one. The line is:
FILE:LINE:COL: (f64 x) found a dyn holding an int, and converted it to f64 — warned once for this site
The bare =FILE:LINE:COL:= is the house shape for a loc-bearing runtime
diagnostic — flan_rt.c's bounds, divide-by-zero and null-allocator sentences
all open that way, and the =flan:= prefix is reserved for the lines that carry
no location (the leak report at flan_dev.c:1765, the argv failure at
flan_rt.c:179). An earlier draft of this warning wore =flan= in front of the
location; it was taken off to match the neighbours.
Both builds warn, dev and release. No precedent was found making a diagnostic
of this kind dev-only: the allocation registry's notes are the one runtime
family a release build drops, and those are a *feature* being disabled, not a
warning being hushed. After the first hit this costs a tag compare and a
linear scan of a handful of entries, which is nothing.
** How it lowers, and why that shape
check.ml's [cast_dyn] builds a branch, not a call that converts:
(let ([s d])
(if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1)
(u32 (flan_dyn_need_f64 s))
(u32 (flan_dyn_need_i64 s))))
flan_dyn_cast_kind answers 1 for a float box and 0 for an int box, traps for
everything else, and warns when the answer disagrees with the target. Each arm
is then an ordinary [Cast] over an ordinary need — *the same node* a typed
operand of that type would have produced.
The alternative was a coercing runtime entry point answering the finished
number, and it was rejected because =(i64 2.5)= is not a bare fptosi in this
compiler: Emit.check_cast range-checks it and signals ArithError when the
value will not fit, and lib/x86.ml does the same. A C function returning an
int64_t would have had to grow its own second opinion about range and NaN, in
a second place, for two backends — a fork of exactly the kind "Arithmetic
semantics do not fork across the two spaces" forbids. With the branch there is
nothing to keep in step, and "x86 tracks LLVM -O0" holds by construction:
programs/dyn-cast.flan prints byte-identical output on both backends,
warnings and trap included.
The generic cast arm — =(t x)= inside a body with ={:where (numeric? $t)}= —
did NOT grow a dyn case and did not need one: the operand's type there is what
the bound admits, and numeric? does not admit dyn, so a dyn cannot reach that
arm. Pinned in test_flan.ml.
** What this repeals
test_flan.ml's row "a keyword with no expectation converts as dyn" pinned
=(i64 :space)= as a *check* error. It is a well-typed program now and a
run-time trap instead; the row became an [accepts] saying so. That is the
whole of the behaviour change outside the new feature.
** For the author: the shims in sand.flan can go
sand.flan defines =dyn->f64= and =dyn->u32=, one-line functions whose only
job is that their parameter slot unboxes. Every call site can now write the
cast directly — =(f64 d)=, =(u32 d)= — and the two defns deleted. Not done
here: sand.flan is the author's WIP and this lane did not touch it.
* Follow-ups from the 2026-09-20 reviews
Small, verified findings the reviews turned up after their lanes had landed.
Each was re-checked against the tree before it was written or fixed.
** runtime/flan_dyn_stub.c is dead, and the author should decide its fate
RECOMMENDATION: delete it. Not done here — it is a file the author added and
removing it is his call, so the facts are written down instead.
*** What it was for, in its own words
Its header says it plainly: "a standing-in implementation of the flan_dyn.h
ABI ... THE MERGE REPLACES THIS FILE WITH runtime/flan_dyn.c. It exists so
that the compiler side of dynamic-by-default can be built and run against the
fixed ABI before the real runtime lands." The merge it names happened. The
real runtime is =runtime/flan_dyn.c=, and lib/dune pastes *that* file — not
this one — into Runtime_src (lib/dune:41, :52).
*** Why it is dead
- No dune rule mentions it, in lib/dune, runtime/ or test/dune.
- No .ml refers to it; no test links it; =flan build= never compiles it.
- The only mentions anywhere are two historical citations in docs —
docs/SPIKE-DUPLICITY.md:58 cites a line number in it, and
docs/handoffs/HANDOFF-dyn-m1.md:131 explains that the stub verified nothing
about root discipline. Both are narrative about a period that has ended;
neither gives the file a live job.
*** It does not compile
Two conflicting-type errors against its own header, both pre-existing and
neither caught by anything, because nothing builds it:
clang -c runtime/flan_dyn_stub.c -Iruntime
flan_dyn_stub.c:90: flan_dyn flan_dyn_from_bool(int32_t v)
vs flan_dyn.h:68: flan_dyn flan_dyn_from_bool(uint8_t b);
flan_dyn_stub.c:293: int32_t flan_dyn_need_bool(flan_dyn v)
vs flan_dyn.h:145: uint8_t flan_dyn_need_bool(flan_dyn v);
So the one thing it could still be — a second implementation the header is
diffed against — is a thing it has already stopped being.
*** The cost of keeping it
It is maintained by accident: the dyn-cast lane added [flan_dyn_cast_kind] to
it (flan_dyn_stub.c:333) alongside the real one. That is a duplicity the
doctrine does not ask for — the same side of the same capability, written
twice — and the copy is the one no test can reach. This batch deliberately did
NOT carry the warning-prefix change below into it, so the two now disagree.
** The cast warning wears the house prefix
See "The warning is once per SITE" above. The =flan= in front of the location
came off; a loc-bearing runtime diagnostic opens with a bare =FILE:LINE:COL:=
everywhere else in the runtime.
** Where the memory classifier overrode the spec: dyn push and put
Written into the memory-diagnostics decision above, where its siblings live:
see "Dyn push and put are not marked" under "Where this overrode the spec,
and the evidence". It had only ever been in a test comment.
** Two stale "kept honest by" claims, corrected
lib/build.ml's note beside the header write and test/dyn_ops.c's own header
both said dyn_ops.c calls every function runtime/flan_dyn.h declares. It does
not, and the corrected header made that visible: [flan_dyn_cast_kind],
[flan_dyn_is_nil], [flan_dyn_need_not_nil], [flan_dyn_map_get],
[flan_dyn_map_set] and [flan_dyn_map_contains] are declared and never called
there. The check the include buys is real but narrower than the claim: for a
function dyn_ops.c *calls*, the call is compiled against the header and the
symbol has to resolve against flan_dyn.o, so a rename, a removal or a changed
argument list is a compile or link error in =dune test=. A function nothing
here calls gets neither. Both comments now say that instead.
* Typed structs do not version; a shape that evolves is a defclass, decided 2026-09-20
The struct-version-word design (plan.org's dev/release table, candidate B in
docs/SBCL-REDEFINITION-NOTES.md) is dropped, not deferred. A typed struct
redefinition that changes layout keeps today's refusal; the author's call —
"let's just ignore it then, we should be using defclass instead." The
division of labour is the two-model one: a shape still being discovered
lives on the dyn side as a defclass, where CLOS-style lazy migration handles
redefinition (its own lane); a typed defstruct is a commitment to a layout,
and changing a commitment restarts the process. SBCL context that settled
it: SBCL also refuses by default (a continuable error), and its
push-through-and-invalidate behaviour is cheap only because its instances
carry headers, which Flan's flat structs deliberately do not.
* A defconst is a compiler const, decided 2026-09-20
The author's words: "defconst should not be computed, it's the equivalent to a
compiler const." So a defconst's initialiser has to be a compile-time constant,
and the refusal is the checker's — [Check.const_defconst_init], called from
[check_global] right after the union refusal it sits beside.
It had to move because the two backends were not refusing the same program.
[Emit.const] refused a computed defconst by name, late and on its way to LLVM
IR; the x86 backend classified globals by [Tast.const_init] alone, so the same
defconst fell into the computed set, was stored by the startup function and was
guarded by an [.init~once.] flag exactly like a defvar. The same split let x86
accept =(defconst g U (U.B {.x 1}))=, a data type case in a constant, which
LLVM refused with the byte-level-encoder message. One refusal in the checker
ends both, and it is the only place that can name the way through.
** The boundary, derived rather than chosen
What a defconst may be is exactly what [Emit.const] can write and what the x86
backend's [data_sym] path lowers, which is [Tast.const_init]'s set: an integer,
float, bool or string literal; unit; a zeroed or uninit value; [None]; a [Some]
of one of these; and a struct literal or array of them. Nothing that compiled
on LLVM before stopped compiling.
Integer arithmetic is in the set and is not an exception to it. [collect]'s
folding pass — [const_int], +, -, *, / and %, over literals and over other
folded constants, to a fixpoint — has already replaced =(/ screen-height
cell-size)= with its answer before [check_global] looks at the initialiser, so
what the refusal sees is an [Int]. That pass is integers only, which is why
=(defconst half f64 (/ 1.0 2.0))= is computed and refused. Widening it would be
a second folder and was not done; a test pins that there is not one.
** What came out
- [Emit.const]'s two refusals are gone. Both are the checker's now. The data
type case one is word for word what it was. The general one gained what the
checker knows and the emitter did not: it names the constant — "the constant
c is computed" rather than "this one is computed" — and it spells the way
through, =(defvar c ...)= or a literal, with the integer arithmetic the
folding pass accepts named beside it. Both are located at the declaration
now rather than at the expression inside it, which is where every other
refusal about a global points and what [next-error] jumps to. What is left
in the emitter is a [failwith] in the file's own idiom: no program reaches
it, and it fires only if the checker's accepted set and [Tast.const_init]
ever stop agreeing.
- The case is searched for through the aggregates, which is what [Emit.const]
did by recursing: =(defconst g S (S {.u (U.B {.x 1})}))= is a case the image
cannot hold just as much as a bare one, and "this is computed" would be
advice nobody could act on. Left to right and first offender wins, so a
computed field written before a case field still gets the general message —
the emitter's own order, since it spelled the fields in order and failed at
the first one it could not spell.
- [emit_global]'s =gconst || const_init ginit= lost its left half. The form no
longer decides anything there; the initialiser does, and a zeroinitializer is
now always a defvar waiting for the startup function.
- x86 needed no edit: it never had a defconst case to delete. It classifies by
[Tast.const_init], and the checker now guarantees a defconst passes it, so no
defconst reaches [Emit.startup_plan] and no [.init~once.] flag is made for
one. The flag machinery for defvar is untouched.
** The test harness that depended on the old rule
test_flan.ml's [infers] read a type off =(defconst probe <expr>)=, which is how
it pinned literal defaulting and every primitive's result — and most of those
probes are calls. It asks [Check.expression] now, the way a session checks an
expression sent from the editor, which is the question the wrapper was only a
way of asking. A defvar could not stand in: only the defconst form takes no
type. The one corpus row that relied on an untyped computed defconst,
=(defconst k (g))= ordered before [g], is a typed defvar and still pins the
order-independence it was there for.
* The third element of a defvar decides, 2026-09-20
The author, deciding it: "if it's 3 atoms then it's dyn", and "dispatch the if
it's a type do the right thing."
So ~(defvar x <type>)~ is the zeroed static global it has always been and
~(defvar x <expr>)~ is a dyn global initialised from that expression at
startup. ~(defvar current-color i32)~, ~(defvar grid [rows [cols u32]])~ and
~(defvar p Point)~ all keep their meaning to the letter; ~(defvar score 0)~ is
a dyn holding 0, and ~(defvar game-data (edn/read-file "x.edn"))~ is what
~(defvar game-data dyn (edn/read-file "x.edn"))~ spells out. The four-element
forms are untouched, ~(defvar x dyn <expr>)~ among them.
This is a step in the direction the "dynamic-first dream" names — the author
wants dynamic by default, lowering to static where it can — and it is the
cheapest one available: the dyn spelling stops needing a keyword, and the
static spelling loses nothing. It is the same dispatch the parameter vector
already makes ([(defn f [x y] ...)] is one annotated parameter if [y] names a
type and two dyn parameters if it does not), now in the one other position
where a name could be either.
** Where it is decided
Half in [Parse.defvar3] and half in [Check.settle_defvars], split by what each
one can know.
Parse settles every form a *shape* settles, and that is most of them: [0], a
string, a map, [[1 2 3]] and [(f "x")] are not types by any reading, so the
global is dyn and the third element is its initialiser; [[4 u32]], [()] and
[(Fn [i32] i32)] are types by any reading and keep today's meaning. Note where
the bracket falls — [[n T]] stays a fixed array, so no [(defvar rows [4 u32])]
changed under this — and that [texpr] is called under a handler, because "does
this parse as a type" is a question its refusals answer.
Two shapes are left, and a name and not a shape decides them: a bare symbol,
and [(head arg ...)] with type-shaped arguments. Both readings leave Parse
together — the [texpr] in the [Ast.Defvar] and an [Ast.Ambiguous] expression
beside it — and [Check.collect] picks, at the point where every type name is
registered and just after [pair_decls], which is there for the same reason.
The type reading wins wherever there is one, and a built-in constructor is
recognised by name rather than by whether [resolve] happened to accept it, so
[(Vec i32 i32)] stays a malformed [Vec] instead of becoming a call to
something named [Vec].
An undecided defvar leaves [collect] as a [Zeroed] or as an [Init] at [dyn] —
that is, as [(defvar x dyn <expr>)] exactly. Nothing downstream has a third
case to learn: the startup lifting, the [.init~once.] re-run guard and the
collector root are the ones that form already had, and neither backend was
touched.
** The ambiguous symbol
One namespace covers every declaration kind ([collect]'s [claimed] table), so
a type and a value cannot share a name and the two readings can never both be
live. What the rule *does* create is a symbol that is neither, where the old
"unknown type foo" would now send a reader looking for the wrong mistake:
: foo is neither a type nor a value, and the third element of a defvar has to
: be one or the other: a type there declares a zeroed global of that type —
: (defvar total i64) — and a value there declares a dyn global holding it —
: (defvar total 0). Nothing named foo is declared as either — did you mean fo?
Both readings, both spellings, and the near miss ranges over the value names
as well as the type names — [near_miss] grew an [~also] parameter for it, and
this is its only caller.
** Pinned
test_flan.ml holds the four spellings with their meanings (the type and
whether anything runs at startup, not merely that they compile), the parse
shapes, the collision refusal and the diagnostic verbatim;
test/programs/defvar-dyn.flan is both readings in one program, pinned in
acceptance at the default, -O0 and --x86; and dev-rerun.flan grew a
[(defvar tally 0)] whose line is 4 after three re-runs, which is the claim
that the new spelling goes through the old guard.
* Classes and generic functions, 2026-09-20 — M2 queue item 6
The recorded decision was "defclass = named dyn map + shape tag; CLOS class
dispatch AND Clojure-style arbitrary dispatch functions", and it is built as
written. The two dispatch styles are one mechanism and not two: a class
dispatcher is the shape tag of the first argument used as the dispatch
function, so a method written for the class ~point~ and one written for the
value ~:point~ are the same branch — which is also why the two spellings are
refused as duplicates of each other.
** The surface, as landed
#+begin_src lisp
(defclass point [x y]) ; a class: named slots, no types
(point 3 4) ; the constructor — the class's own name
(class-of p) ; :point, and nil for anything else
(get p :x) (put p :x 10) ; the slots are map keys; nothing new
(defgeneric area [self] dyn) ; CLOS: dispatch on the class
(defmethod area point [p] (* (get p :x) (get p :y)))
(defmulti describe [x] dyn (get x :kind)) ; Clojure: the body is the dispatch
(defmethod describe :square [s] (get s :side))
(defmethod describe :else [s] "something else")
#+end_src
- *A slot is a key.* An instance is a dyn map, so ~get~, ~put~, ~has-key?~
and ~len~ are how one is read and written, and no operation was added for
any of it. ~(len p)~ is the slot count.
- *The constructor is positional*, one argument per slot in the order they
were written, and it is an ordinary ~defn~ — so its arity refusal, its cell
in a dev build and its behaviour under redefinition are the ones every
function already has. The named-slot spelling is deferred; see below.
- *A method has no return slot.* The generic states the return type once, for
every method written for it, which is also what makes the parse
unambiguous: the vector is always the third form.
- *Every parameter of a generic and of a method is dyn*, written or not, and
a slot that is not a bare name is refused. That keeps these forms off the
undecided-pairing path a ~defn~'s vector is on: a vector that may hold only
names can be read by the parser, where a ~defn~'s cannot be read until
every type name is known.
- *A dispatch value is a literal* — a class's name, a keyword, a string, an
integer, ~true~, ~false~, or ~:else~ for the arm everything falls through
to. ~:else~ and not Clojure's ~:default~, because ~match~ already spells
"none of the above" that way and two words for it would be one too many.
~:else~ is the last arm whatever order it was written in.
- *A miss signals.* ~(defstruct NoMethod [generic string value dyn])~ in the
prelude, signalled with ~error~, carrying the name written at the generic
and the value the dispatch actually produced. A condition and not a trap,
because a miss is something a program can be written to answer;
~handler-case~ around the call is the shape, and a ~:else~ method is the
other answer. No restart is established at the miss, which is
BoundsError's decision taken for BoundsError's reason. ~value~ is the first
~dyn~ field in any condition here; the per-type descriptor an item-2 struct
carries is what the collector reaches it by.
** The shape tag: a header field, not a reserved key
This queue item's own note said "named dyn map + shape tag", and the obvious
reading was a reserved entry in the map. It is a field in the object's header
instead — an interned ~kw_entry *~ in the map arm of ~flan_obj~'s union — and
the departure is deliberate.
An entry would be counted by ~len~, walked by both renderers, and compared by
~dyn_equal~'s key loop. Every instance would answer a length one larger than
its slot count, print a key nobody wrote, and be one ~put~ away from having
its own class changed. A header field cannot be reached by ~get~ or ~put~ at
all, so the question of a user key colliding with it does not arise rather
than being answered by picking an unlikely spelling.
It cost nothing. The view arm of that union is 24 bytes, so the map arm
growing from 16 to 24 does not grow the union, and ~sizeof(flan_obj)~ is 48
before and after — checked, not assumed. It needs no marking either: an
interned keyword entry is immortal by construction and is not a collector
object, which ~mark_value~ states by following ~BOX_OBJ~ and nothing else.
The tag is read in exactly four places in flan_dyn.c: ~class-of~ answers it;
~dyn_equal~ compares it, so two instances of one class compare by their slots
and an instance is never equal to a plain map with the same entries
(Clojure's answer for a record beside a map); and *both* renderers write it —
~render~, which is what ~print~ goes through, and ~say_render~, the 96-byte
sentence a trap prints, so a dyn trap naming an instance says which class it
was. The spelling is ~#point{ :x 1 :y 2}~, Clojure's own for a record.
The tag is built from the class's *qualified* name, and the qualifier is the
**importer's alias** rather than anything the defining package chose — the
same class imported as ~a~ and as ~zz~ tags its instances ~:a/point~ and
~:zz/point~. That falls straight out of [Load]'s rename, and it is right for
the dispatch, which resolves the class name through the same rename and
therefore agrees with it. What it is *not* safe for is a hand-written
dispatch value: ~(defmethod g :a/point ...)~ is a keyword and nobody
qualifies it, so it is coupled to one import's alias and silently answers for
nothing under another. Write the class's name, ~(defmethod g point ...)~,
which is renamed with everything else. Two packages' own ~point~ classes are
two classes either way, which was the property wanted.
** How it is built: a pass, not a macro
None of the four forms reaches the checker. ~lib/classes.ml~ rewrites the
whole declaration list at the top of ~Check.build_program~, exactly where
~Shim.expand~ rewrites a ~declare-c~: a class becomes its constructor, a
generic becomes one function whose body binds the dispatch value and compares
it down a chain, and a method becomes a branch of that chain.
A macro sees one form and this needs the whole list, because a method may be
written above its generic, below it, or arrive at a reload an hour later.
Running over the flat list is also what makes the dev loop work: a reload
rebuilds every dispatch from the session's whole set of declarations.
*The method bodies are inlined rather than lifted into functions of their
own*, and that is the load-bearing choice. A generic is then exactly one
top-level name, so adding a method to a running program is the ordinary
redefinition of one function, through the cell the call site already goes
through. ~session.ml~ names the generic alongside the method's own
declaration name for that reason — without it a ~C-c C-c~ on a ~defmethod~
would install something no call site reads. A method still declares a name of
its own, ~area@:circle~, which is what makes re-evaluating one a replacement
and evaluating a new one an append; no function is emitted under it. Proved
end to end against a real daemon (~test_dev.ml~, "a method added to a running
program"), not only at the session's report.
A method's own parameter names are bound from the generic's *in parallel*,
through temporaries in the unspellable ~[~]~ namespace. A [let] binds in
sequence, so the pairwise spelling reads a name it has just bound: a method
[[b a]] under a generic [[a b]] would be handed its first argument twice and
the second would be unreachable. Both the swap and the one-step shift are
pinned in the survey program, where the values are what is wrong rather than
the types.
The cost, recorded rather than hidden: *a method is not separately callable
and is not a frame of its own*. A break loop under a method shows the
generic. And the generic's own parameter names stay in scope inside a method
that renamed them, so a body reaching for ~self~ where it declared ~p~
silently resolves instead of being refused — small, and closing it would mean
giving the dispatcher unspellable parameter names, which is what the
inspector reads.
** Deferred, each with the reason
- *Inheritance.* plan.org's own rule is that method specificity and ambiguity
rules are required before inheritance or multiple dispatch is enabled, and
with single dispatch on literal values there is no specificity question at
all: two methods either answer for the same value, which is refused, or for
different ones. A hierarchy would create the question, and the author never
asked for one.
- *Multi-argument dispatch.* plan.org names it as the later extension, for
~(collide Player Enemy)~. It wants the specificity rules above.
- *~:before~, ~:after~, ~:around~ and ~call-next-method~.* They only mean
something once methods can be ordered by anything but equality, which is
the same gate inheritance is behind.
- *Named-slot construction*, ~(point {:x 1})~ with an omitted slot meaning
nil — the dyn twin of ~(Cursor {.src s})~ with its omitted-is-zeroed rule.
Positional is what a generated ~defn~ gives for free, arity included; the
named form is a checker special case and was not worth one at v1.
- *Unknown-slot checking at ~(get p :z)~.* The one compile-time win a
declared slot set makes possible (docs/SPIKE-DUPLICITY.md §8 names it), and
it needs the checker to know the class of an expression — class-typed
tracking on the dyn side, which dyn deliberately does not have. A class
adds a tag and a dispatch, not a static slot discipline.
- *Computed dispatch values.* Clojure registers a method under any value
because registration there is a run-time call; here it is compile-time, and
the method's declaration name is built from the value.
- *~nil~ and floats as dispatch values.* ~:else~ covers the nil case, which
is the common one (~class-of~ answers nil for anything that is not an
instance); a float compared for equality is a trap waiting to be sprung.
- *Class redefinition and migration* — plan.org's ~redefine-class~ /
~migrate-instances~. A heterogeneous map has no layout to be stale, so
nothing breaks today when a class gains a slot: old instances simply lack
it. Enumerating live instances is the part that is missing, and it is the
pool's question rather than this lane's. **Built after all, and without
the enumeration**: see "Lazy instance migration" below, where the answer
turned out to be CLHS 4.3.6's — do not walk the heap, stamp the instances
and migrate each one when it is next touched.
- *The JS backend.* It refuses dyn wholesale, so none of this compiles there.
Same parking as the string-equality hole above.
** Two things found on the way, neither about classes
- *The x86 backend's redefinition module never emitted the per-type dyn
descriptors.* ~Emit.redefinition~ has always emitted them, by going through
~finish~; the x86 twin ended at the rodata section and stopped. Nothing had
reached it, because a redefined body had to construct a struct holding a
dyn to need one, and until ~NoMethod~ there was no such struct a
compiler-written body could build. It is not a bad read at run time — a
descriptor label is local, so ~ld~ refuses the module with an undefined
symbol. Fixed with one line beside the same call in the executable path.
Emitting them turned up the second half: ~descriptors_asm~ wrote them into
~.rodata~, and a descriptor holds the address of its own offset table. A
relocation in a read-only section is a ~DT_TEXTREL~ — ld warns about it in
a PIE and refuses it in a shared object — so the section is now
~.data.rel.ro~, which exists for exactly this and is what both the
executable and the reload module use. Verified with ~readelf -d~ on a
reload module from each backend: no ~TEXTREL~, descriptors in
~.data.rel.ro~.
*Still unexercised, and for the next sweep rather than this lane:* marking
THROUGH a descriptor that an x86 reload module emitted. What is proved is
that the module links and runs; what is not is a collection happening while
a live instance of a dyn-holding struct sits in a frame of a body that
module delivered. The LLVM path has been exercised since item 2; this one
has existed for a day.
- *A dyn value answered by ~eval-expr~ never reaches the reply's ~:value~.*
It renders to the program's own stdout, which arrives on a *later* reply's
~:output~ — the dyn-global rows already read one that way and say so, and
~(+ 2 3)~ answering "5" beside ~(area (point 3 4))~ answering "" is the
whole of the difference. Left standing: where a dyn expression's value
should surface is a question about the editor protocol, not about this
lane. The dev test works around it by comparing inside the expression, so
what crosses the wire is a typed 1.
** What was run
~dune test --root .~ green (exit 0, no FAIL lines) after each commit, and
again on the rebase onto the defvar-dyn lane — whose ~load.ml~ arms are the
~defvar~ one and whose ~ast.ml~ arm is the ~Ambiguous~ initialiser, disjoint
from the four class arms beside them; both sets were read against each other
by hand rather than trusted to the auto-merge. Three acceptance rows for
~test/programs/dyn-class.flan~ — default, ~-O0~ and ~--x86~ — and a three-way
diff of the program's real output across the same three, captured by hand
before the rows were written and again after the rebase. It is in
~test_sanitize.ml~'s list; per the sweep policy the sweep itself was not run.
** Found while running it: ~dune test~ exits 1 at random, and has since before
this lane — FIXED
~test_dev.ml~'s ~trap_park~ rows are racy, and when they lose the race the
whole test binary dies with ~Fatal error: exception Flan.Wire.Closed~ — exit
1 with no FAIL line anywhere, which is the worst shape a failure can have
given that the sweep policy says a lane is judged on the exit status.
The mechanism: ~trap_park~ polls with ~ask~, which is a bare ~Wire.send~ /
~Wire.recv~ pair with nothing around it, and the program it is polling has
just aborted at the break loop. If the daemon exits between the send and the
recv, ~Wire.recv~ raises ~Closed~, nothing catches it, and every row after it
— in this lane's case the new class daemon among them — never runs. Both
observed failures landed at the same row, ~dev-trap-null-alloc~.
*It is not this lane's.* Measured on a detached worktree at dev-loop's tip
(c4e0725) with nothing of this lane in it: 2 of 5 runs exit 1 with the same
exception at the same row, against 2 of 5 on this branch. The rates match
because the code is the same.
Not fixed here, deliberately: the fix is to catch ~Closed~ in that poll and
read it as the program having ended, which is a claim about what those rows
mean and belongs to whoever owns them. Flagged rather than patched.
* Two struct spellings, 2026-09-20
Both were DISCUSS.org items, both diagnosed there as the same parse/check
boundary problem, and both are decided by the author on 2026-09-20.
** A. A bare ~{.field v}~ takes its type from the position it stands in
The note's own diagnosis was right: the refusal ("a bare map is not an
expression; write (Type {.field v})") sat in ~Parse.expr~, before any
checking, so ~(defn get-mouse-cell [] Cell ... {.row r .col c})~ could not
work no matter what the checker knew. It has moved.
Parse now builds ~Ast.Bare~ — a field list with no name — out of the same
~struct_fields~ the named form uses, so the two field lists are parsed by one
function and cannot drift. ~Check.check_bare~ reads the type name off the
expectation and hands that same list to ~check_struct~. That is the whole
feature: ZII for an omitted field, the unknown-field refusal, the
duplicate-field refusal, their notes and their error kinds are not "the same
as" the named form's, they *are* the named form's, reached by the same call.
*Accepted* — every position that carries a want:
- a defn's return position (the case from the notes),
- an argument of a call, the only argument or a later one,
- a field of an enclosing literal, at any depth,
- a typed place being ~set~, a local or a field of one,
- a union want, which reaches ~check_union~ by the same route.
*Refused, at checking, by name*:
- no want at all (a ~let~ binding, a body form that is not the last): "does
not say which struct it builds — the fields alone do not name a type",
naming both ways out, and saying why a let binding is not one of them (a
local takes its type from its value).
- a ~dyn~ want: refused, and told that a dyn map's keys are keywords. This is
the boundary that mattered most. Braces at a dyn want are the dyn map
literal and stay exactly that; a ~.field~-keyed brace was never part of
that spelling and is not being quietly given a second meaning now.
- a want that is not a struct type at all: "i32 is expected here, which is
not a struct type".
- a data type's name as the want: inherits the existing message, which names
the cases — ~D~ is not specific enough, a value of ~D~ is one of its cases.
One thing had to move in ~Check~ as well as in ~Parse~. ~(g {.row 2})~ is
parsed as a struct literal named ~g~, because the parser's struct-literal arm
fires on the *shape* of the single argument and has no table to consult; it
used to be refused with "g is a function, not a struct". Now, when the head is
a name that would actually resolve to a callee (a defn, a generic, or a local
of function type — ~callable~), it is handed back to ~named_call~ as the call
it was written as, with the fields rebuilt into the ~Ast.Bare~ node the parser
would have made anywhere else. An unknown name keeps the old "unknown struct"
report, because that shape is usually a misspelled struct name. ~(name {})~
keeps its old refusal untouched: the empty braces are genuinely ambiguous
between the zero-field struct literal and the empty dyn map, and that one does
have the let-binding fix its message already names.
** B. ~(Cell 1 2)~ positional, and arity is exact
The note's diagnosis again: the parser cannot tell ~(Cell 1 2)~ from any other
call, so ~Check~ does it. The decision sits on the last arm of ~named_call~
after a local of function type, after a generic, after the global function
table — where the old "Cell is a type" refusal used to be.
*No collision is possible.* ~collect~'s ~claimed~ table spans every
declaration kind, so one name is one declaration and a ~defstruct Cell~ beside
a ~defn Cell~ is "Cell is defined twice" before any of this is reached. A
~(defclass point [x y])~ constructor is a real ~defn~ that ~Classes.expand~
wrote before checking began, so ~(point 1 2)~ resolves in ~env.fns~ two arms
above the struct one and never reaches it. Pinned both ways.
*** The arity decision: exact, no partial ZII
Positional construction gives every field or it is refused. This is not a
retreat from ZII — ZII is what the designated form does, and ~(Cell {.row 1})~
still zeroes ~.col~, which is where the message points. The reason is that a
positional list cannot *say* which field it left out. ~(Cell 1)~ reads as a
Cell with one field given, and which field that is depends on a declaration
order the author is free to change later; a trailing field silently zeroed
there is the field-reorder hazard at its very worst, arriving as a wrong value
rather than as an error. So a short list is a refusal that names the first
field it did not reach:
: Cell has 2 fields and 1 was given positionally — .col has no value.
: Positional construction gives every field, in declaration order; to give
: some of them and zero the rest, a struct value is written
: (Cell {.field value ...})
with ~declared_note~ pointing at the declaration. A long list points at the
first extra argument and shows both spellings. Odin's positional literal takes
the same line, and the repo's ZII philosophy is not against this: ZII is about
what an *omitted* field means, and this refusal is about a spelling that
cannot express omission at all.
*** The field-reorder hazard, accepted
The author's words, on B's remaining cost: "B can be fixed with refactorings
later on when we decide to add it." Reordering a ~defstruct~'s fields silently
changes what every positional call site builds, and nothing in the compiler
catches it when the types happen to line up. Accepted as the price, with
refactoring tooling named as the eventual answer rather than a compiler rule.
*** Argument type errors
Each argument is checked against its own field's type by ~map2_lr~ and
~~want~~, exactly as a call's arguments are checked against its parameters —
so the mismatch is reported at the argument, in the words a call's argument
already gets ("expected f32, found string"). What is added is a *note*,
"this is Cell's field .col", plus ~declared_note~, because a positional call
site is the one place the source does not show the field name. The note is
attached only to a diagnostic raised at that argument's own location, and it
only ever names the position — which is true whatever went wrong there — so a
nested failure inside the argument cannot be miscaptioned by it. Naming the
field *in the message* would need an error-context helper ~loc.ml~ does not
have; not built here, since the note carries the same information and adding
the helper touches a file the Elm-messages lane is in.
** Backends
Zero edits, and nothing to edit. Both features are gone by the time ~Check~
finishes: a bare literal becomes the ~Tast.Make~ the named form already built,
and a positional call becomes that same node with its arguments put into
declaration order. ~Emit~ and ~X86~ have no case for either. The three
acceptance rows for ~test/programs/struct-ergonomics.flan~ — default, ~-O0~,
~--x86~ — print the same fifteen lines, which is what says so out loud.
** One existing test's needle had to change, and one did not
~test_flan.ml~'s two "bare struct-shaped braces still refuse" rows refused at
*parse* and now refuse at *checking*, for a different and better reason; their
needles and the comment above them were rewritten together. The row for
~(Cursor {:src s})~ did not have to move: that form is now a Cursor built from
too few arguments, and the new refusal still contains "a struct value is
written (Cursor {.field value ...})", which is what its needle asks for. Its
comment was corrected anyway, since the *reason* it refuses changed.
** What was run
~dune test --root .~ in the lane's worktree: exit 0, no FAIL lines. The first
run exited 1 with ~Fatal error: exception Flan.Wire.Closed~ and no FAIL line
anywhere — the known ~test_dev.ml~ ~trap_park~ race recorded under the classes
lane above, not this lane's; the rerun was clean. The survey program was run
by hand at all three settings and its output diffed across them before the
acceptance rows were written. Not added to ~test_sanitize.ml~: the program
allocates one small dyn map and nothing else, so there is nothing for ASan to
find that ~dyn-map.flan~ does not already exercise.
* Six dogfooding items off DISCUSS.org, 2026-09-20
Each of these is an author note from a session of writing Flan rather than a
report from a test. They are small and they are unrelated to each other, which
is why they went in one lane: every one of them is a place the language said no
for no reason, or did not have a name it should have had.
** (when test) with no body, and the family it turned out to belong to
[lib/parse.ml]'s ~when~ required ~body <> []~ and failed "when is (when test
body ...)". The guard is gone and an empty body is the ~Do []~ that ~(do)~
already means. A ~(when)~ with no test at all is still refused, because there
is nothing to branch on.
~unless~ is a prelude macro now, not a special form, and carried the same
restriction as ~(< (len args) 2)~. It is ~(< (len args) 1)~, and its
unknown-name report narrowed with it: ~unless-takes-a-test~ rather than
~unless-takes-a-test-and-a-body~, since the body is no longer part of the
claim.
The author then added the third member of the family, and it turned out to be
two different questions:
- =(defn foo [bar i32] ())= — a declared return type of () and no body — was
*already legal*, and the refusal for the other case was already the right
one: [Check] says "foo returns i32 but has no body" at the declaration. No
change; both are pinned now, which they were not.
- =(fn [])= was refused by the parser, by the same ~body <> []~ guard ~when~
had. Dropped. An fn declares no return type, so "legal exactly when the
return type is ()" has to be decided somewhere else, and the position it is
written in is the only thing that knows: check_fn now refuses an empty body
at a non-unit want — "an fn with no body answers (), and this one is in a
position that wants i32". *That refusal is new and it was needed:* without
it the empty body fell straight through check_fn's ~List.rev fbody~ match,
the fn compiled, and the call read a return value nothing had written. So
relaxing the parser here opened a hole that had to be closed in the checker,
which is not true of ~when~ or of ~defn~.
** () as a unit value in expression position — considered and dropped
The author, 2026-09-20, closing the question DISCUSS.org left open beside
=(rl/with-drawing ())=: making bare ~()~ a unit value in expression position
was considered and is not wanted. Empty forms doing the right thing — the
three above — covers the need that made it look attractive, and ~()~ stays
unspoken-for in value position on purpose, against the possibility that the
language grows lists later and wants the spelling. (Paraphrased from the
author's note, not quoted.)
So ~()~ remains the type-position spelling of Unit and nothing else, and the
guard below is written against that rather than around it.
** (rl/with-drawing ()) — a body that was not written, spelled the second way
[vendor/raylib/modes.flan]'s guards caught zero arguments and not one argument
that was itself ~()~, so the latter was spliced into the expansion verbatim and
the report came out of the middle of the expanded ~do~ saying ~()~ is not an
expression — several forms from anything anyone wrote. All five ~with-*~ macros
now treat a lone ~()~ where the body goes as no body, answering the same
unknown-name they already answered for the missing one.
Only a lone ~()~, and only in the body position. ~()~ as a camera or a render
target is left to fail on its own: nothing the macro could say about it would
be truer than what the compiler says.
The macros needed a predicate they did not have. ~form-items~ cannot tell ~()~
from a symbol — it answers the empty slice for both — so [lib/prelude.ml] grew
~form-empty-list?~, which matches ~Form.List~ and asks its length.
** (comment ...), built in
A prelude ~defmacro~ answering ~(do)~ and reading none of its arguments, which
is the whole feature: a macro's arguments are raw Form and are never checked as
expressions, so what is inside never has to be a program. The pinned test puts
an unknown function, a wrong arity, ~(+ 1 "two")~ and a field that does not
exist inside one and compiles it.
The one rule it does obey is the reader's — balanced delimiters, legal tokens —
because reading happens before any macro runs. ~#_~ is the other spelling and
they are not rivals: ~#_~ is the reader's and discards the one form after it,
so it works in argument position; this is a form of its own and takes any
number, which is what a parked block wants.
** inc/dec, ++/--
The four the note spells out, in the prelude rather than per project. A word
for the pure pair, C's punctuation for the mutating pair, so ~(inc i)~ in an
argument and ~(++ i)~ as a statement cannot be confused the way C's ~i++~ and
~i+1~ can.
Generic for free, and verified rather than assumed: the pinned program runs
~inc~ over i8, i16, i32, i64, u8, u16, u32, u64, f32, f64 and a dyn, and prints
the answers. Nothing in the four macros mentions a type, because ~+~ and ~-~
already work at all of them and a macro has no type to get in the way.
*The accepted tradeoff, documented at the definition:* ~(++ PLACE)~ expands to
~(set PLACE (+ PLACE 1))~, so the place is read once and written once and is
therefore *evaluated twice*. Free for a variable, a field or a deref. Not free
for ~(at arr (next-index))~: ~next-index~ runs twice and the read and the write
land on different elements. Not fixable here — macros are non-hygienic by
decision, and a macro cannot bind a temporary for a *place* without a reference
type the language does not have. rl/with-drawing and rl/with-mode-2d already
take the same trade on their arguments.
The note's four macros have no arity guard, and they needed one: ~(inc)~ would
have indexed past the end of its own argument slice and failed inside the
compiler rather than saying anything about the program. Each guards on
~(!= (len args) 1)~ — both too few and too many — and each is pinned.
** Type-limit constants
[lib/prelude.ml] gained i8/i16/i32/i64 and u8/u16/u32/u64 max and min, and
f32/f64 max, min-positive and epsilon. Kebab and the type's own name, following
~ns-per-second~: ~i32-max~, not ~INT_MAX~. Each carries its type, so ~i32-max~
where a u8 is wanted is a type error rather than a silent 255.
The u*-min constants are all zero and are all there. A family with a hole in it
is worse than four lines that say nothing surprising.
There is no ~f32-min~, and the absence is the design. A float's least value is
the negation of its greatest and needs no constant; what a caller reaching for
"min" actually wants is the smallest positive one, which is a different number
entirely. Naming either of them ~f32-min~ would put the collision at the worst
possible place, so the name says which it is: ~f32-min-positive~, the smallest
*normal* value, as Rust's MIN_POSITIVE does.
*u64-max is written in hex and has to be.* The reader parses a decimal integer
through ~Int64.of_string~, and 18446744073709551615 does not fit one;
~0xFFFFFFFFFFFFFFFF~ is read as the 64-bit pattern it names, which is what a
u64 literal is here — [Check.in_range] accepts any pattern at 64 bits unsigned
for exactly this reason. i64-min's decimal *does* fit, being i64's own least
value, so it is written the ordinary way.
*Every value is pinned against an independent derivation, not against itself.*
A wrong constant compiles — that is the whole hazard — so
[test/programs/limits.flan] does not compare any constant to the way the
prelude spells it. The integers are printed, and the expected text in
test_acceptance is the decimal spelling written out from the definition of each
type; an integer's decimal rendering is exact, so that comparison is the whole
value. The floats cannot be pinned that way, because printing one is snprintf
"%g" and 3.40282e+38 is equally true of f32-max and of a neighbourhood around
it — so each is *derived* by exact power-of-two arithmetic and compared for
equality. Every step of those derivations is exact in IEEE-754, and the two
that are not powers of two have representable operands and a representable
product:
| constant | derivation | bit pattern |
|------------------+-------------------------------+--------------------|
| f32-epsilon | 2^-23 | 0x34000000 |
| f64-epsilon | 2^-52 | 0x3CB0000000000000 |
| f32-min-positive | 2^-126 | 0x00800000 |
| f64-min-positive | 2^-1022 | 0x0010000000000000 |
| f32-max | (2 - 2^-23) * 2^127 | 0x7F7FFFFF |
| f64-max | (2 - 2^-52) * 2^1023 | 0x7FEFFFFFFFFFFFFF |
and each epsilon additionally against the property its name promises — adding
it to 1.0 moves, adding half of it does not — and each max against there being
nothing finite above it, since doubling one overflows to an infinity.
The program runs on *both backends*, and that is not ceremony: materialising a
full-width u64 immediate and an f64 bit pattern is a different job in LLVM and
in the hand-written x86 backend, and a lowering that truncated one would print
a number this row catches and nothing else in the suite does. Both print
identical text.
*No infinity or NaN constant, and none is possible to write down.* The reader
has no literal for either. ~(/ 1.0 0.0)~ is the only route to an infinity
today, and under the defconst-is-const rule decided the same day it is not one
a defconst can take: the folding pass is integers only, so a float division is
a computed initialiser and refused by name. So an ~f64-infinity~ defconst is
not available without either a reader literal or a second folder, and neither
is this lane's. Recorded, not added. The *runtime* test for one is in the
prelude already and limits.flan reuses it: an infinity is the value that equals
its own double and is not zero.
** {.row .col} — and the collision the note said was not there
DISCUSS.org: "No obvious grammar collision — nothing currently matches a bare
.field symbol on its own." *That is false*, and it was worth checking before
relying on it. [dmap]'s pair arm takes any pattern in head position, and
[destructure]'s first arm accepts any ~Sym~ as a name — a dotted one included.
So before this change:
- =(let [{.x .y} p] ...)= parsed, as "bind a local called ~.x~ to field ~y~",
and the program failed later with "unknown name x" pointing at the *use*.
Verified against the compiler, not reasoned about.
- an odd number of bare fields hit the ~[odd]~ arm and was refused, which is
where test_flan's =rejects_check "a field name with no pattern before it"=
came from.
So the dot in head position did have a meaning; it was just never a useful one.
The new arm is checked *before* the pair arm and takes both readings away. The
~[odd]~ arm survives for the case it was actually written for — a plain name
with nothing after it, ~{a}~ — and that test is now two: the old one inverted
to an ~accepts~, and a new one on ~{a}~.
Where it works: ~let~, and nowhere else, which is where ~{name .field}~ works
today. Destructuring binds in ~let~ only. A match arm is *not* a second
position the shorthand had to reach: a struct pattern has never worked in one,
and the refusal there is the match grammar's own — "expected a pattern, found
{a .x}" — not [no_pattern], which is what a defn parameter, an fn parameter
and a dotimes counter get. Checked against the compiler rather than read off
parse.ml's comment: ~{.x .y}~ and ~{a .x}~ in a match arm produce the same
refusal as each other, which is the claim that matters — the shorthand
inherited the existing rule rather than changing it.
An unknown field gets the named form's refusal unchanged, because it is the
same field access underneath: "Point has no field z" with the declared_note
listing the fields there are.
** Pinned
- test_flan.ml: ~(when c)~ parses to if + empty do; ~(when)~ still refused;
~(fn [])~ parses with an empty body; ~(fn)~ still refused; a defn returning
() with no body accepted and one returning i32 refused; an fn with no body
accepted at a ~(Fn [] ())~ want and refused at a ~(Fn [] i32)~ one; the
~{.x .y}~ shorthand accepted plain, mixed with a pair, and nested; ~{.z}~
refused by field name; ~{.x}~ inverted from a refusal to an ~accepts~; ~{a}~
refused; and both ~{.x .y}~ and ~{a .x}~ refused identically in a match arm,
which is the "wherever the named form works" half of the claim.
- test/programs/rl-with-empty.flan and rl-with-empty-arg.flan, through
test_acceptance's ~refuses~: the two guard shapes, a body starting at
argument zero and a body starting after a camera, each given a bare ~()~
and each answering the name the zero-argument case already answered. Never
built, which is how rl-with-reject.flan beside them works and is why these
need no raylib on the machine.
- test/programs/prelude-macros.flan, plain and -O0: ~comment~ with four
different kinds of garbage in it; inc/dec over eleven types; ++/-- over a
local, a field, an element and a deref; empty ~when~ and ~unless~ bodies.
- test/programs/limits.flan, plain, -O0 and --x86: every constant, as above.
- test/programs/destructure.flan gained ~shorthand~ and ~shorthand-mixed~
rows, so the shorthand is in the program that is the destructuring test.
- test_acceptance.ml: the arity guard of every new macro, by the name it
answers, plus ~unless~'s narrowed one.
** Note for the concurrent lanes
The diagnostics lane owns check.ml's message strings. This lane added *one* new
message at a *new* site — check_fn's empty-body refusal — and rewrote none. The
parse.ml edits are structural: a dropped guard in ~when~, a dropped guard in
~fn~, a new arm at the top of ~dmap~. Expect a rebase, not a conflict of
intent.
* The diagnostics pass, 2026-09-20
Worked from ~docs/DIAGNOSTICS-AUDIT.md~, which is tracked as of this lane's
first commit. Graded against the contract the audit sets out: show the code
with the caret, say what was understood, say what conflicts, name the fix.
** Reached
Worst-20 ranks 1, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
and the runtime half of 2. All four defvar follow-ups. Two from the author's
dogfooding notes in DISCUSS.org: the foreign-spelling list, which answers
~int~ with ~i32~ instead of a lecture about type variables, and the
two-element ~defconst~ whose bracketed type read as an array literal.
** Not reached, each with the reason
*** Rank 8: ~unhandled Boom~ has no location
Not a copy of the dyn-trap work, and the difference is the calling
convention. ~flan_error~ takes five integer arguments — the type id, the
condition, the channel, and the name as ptr+len — which is rdi through r8.
A ~(loc, loclen)~ pair makes seven, past x86-64's six argument registers, so
~lib/x86.ml~ would need stack-argument passing at a call site whose own
comment two hundred lines up says "the channel lands in r9 and the register
file is exactly full". The dyn entry points took the pair without any of
that because none of them was near the limit.
The rest of rank 8 — the condition's field values, and the handlers that
were in scope — is separate work again and has no ABI question in it.
The audit's gap 4 names a dev-side half of this: ~flan_trap_hook~ hands
control to a session that is in-process with the compiler and *can* read the
source, so a real caret at runtime belongs in ~lib/dev.ml~. That file is
another lane's and the audit already wrote it up as a hand-off.
*** Rank 20: the fn-literal arity message
Re-read and judged already satisfying. The audit asks it to name the
parameter list it was measured against; it prints the whole ~(Fn [T ...] R)~,
which is that list. Left alone rather than churned.
*** ~trap_oom~ in flan_dyn.c
The other three trap printers took the location pair. This one is reached
from ~gc_alloc~, which has no site to be given: every allocation path in the
file would have had to carry one for a sentence that is about the host
refusing memory rather than about the program. ~trap_range~ has the pair and
every caller passes NULL, so giving ~at~, ~set-at~ and ~push~ a site later is
a call-site change and not another round of signature churn.
*** The audit's structural gaps 3, 5 and 6
Printing the stable ~kind~ at the end of the first line, the
"understood / conflicted" clause order as a writing rule, and non-cascading
multiple errors through ~Loc.sink~. Each needs a decision from the author
rather than work, which is what the audit says about them too.
** Two behaviour changes, not only wording
~(defn idx [v i] dyn v)~ was *refused* and now compiles as two dyn
parameters. The rule is the digits: this language sizes its machine types in
the name, so a typo keeps them — ~f65~, ~i33~ — and a parameter called ~i~ or
~n~ has none. ~pair_params~'s own comment says that is what the feature is.
A ~defn~ whose name is a builtin's is still not refused. The builtin still
wins every call and the definition is still unreachable; what changed is that
the arity message says so and notes the definition. Refusing the shadowing is
a language decision and was left to the author.
[Superseded the same day by the author's decision — see "Shadowing a builtin"
below. The builtin no longer wins, the definition is no longer unreachable,
and the arity note this paragraph describes has been removed along with the
world it described.]
* ~int~ and ~float~ as builtin aliases, 2026-09-20
The author, on the foreign-spelling list the diagnostics pass had just
landed: "I think we can make an exception for int and float."
Exactly those two. ~int~ is ~i32~ and ~float~ is ~f32~; ~integer~, ~long~,
~double~, ~uint~, ~str~ and the rest keep the teaching refusal, and ~usize~
stays off the list entirely for the reason already recorded there — its width
depends on the target.
** Spelled as machine types, not as prelude aliases
Two implementations were on the table: two ~defalias~es in the prelude, or
two entries where ~i32~ and ~f32~ already resolve. The second, and the thing
that decides it is the cast.
~Check.is_cast~ asks ~Types.ikind_of_name~ and ~Types.fkind_of_name~ whether
a head names a primitive. It does not look in the alias table, and no user
alias is a cast head today. A prelude ~(defalias int i32)~ would therefore
have given ~(int x)~ no reading while ~(i32 x)~ had one — a spelling that
works in type position and nowhere else, which is a second-class name and not
what was asked for.
So: ~"i32" | "int" -> Some I32~ and ~"f32" | "float" -> Some F32~, plus both
names on ~Types.primitive_names~. That last is not decoration —
~type_named~ reads that list, and it is what decides whether ~(vec-new int)~
names an element type and whether a three-element ~(defvar x int)~ reads its
third element as a type. Without it the identity would have been
type-position-only again, one layer down.
Every other path is reached without learning the word: the resolver, the
parameter-vector pairing, the ~$t~ refusal, the near-miss candidates, the
~let~ binding-vector annotation hint. Nothing in ~emit~, ~x86~, ~js~ or the
runtime changed, or could have.
** What the user sees: ~i32~, always
~ikind_name~ and ~fkind_name~ are the only way back from a type to a name and
they have no ~int~ to give. So the erasure is total and in one direction: a
program may write ~int~ everywhere, and every error message, every eldoc
signature, every inspector line and every DWARF type name says ~i32~.
~(defn f [a int] int ...)~ reports as ~(Fn [i32] i32)~. A mismatch at a site
spelled ~int~ says "expected i32". Both pinned.
This is the same erasure a user ~defalias~ already has and is the honest
answer: the alias is a spelling, the type is the type.
** Redefinition: true ones are no-ops, false ones are refused
The author's own programs contain ~(defalias int i32)~, written before there
was a builtin. The rule is decided by the target, at registration:
- ~(defalias int i32)~ and ~(defalias float f32)~ — accepted, and nothing is
written to the alias table. The declaration is true, it is now redundant,
and deleting the line is a cleanup rather than a fix.
- Anything else — refused: "int is a builtin alias for i32 and cannot be
redefined as i64 — delete this defalias, or give the type another name".
The alternative was the ~arity~ precedent, where the builtin won and a note
surfaced at the error the shadowing caused — a precedent deleted later the
same day, when shadowing a builtin became legal and the user's definition
started winning instead (see "Shadowing a builtin" below); the reasoning
below stands either way, because neither world has anywhere to put the note.
It does not transfer: a
~(defalias int i64)~ has no later error site to hang a note on. ~resolve_name~
reaches ~ikind_of_name~ before the alias table, so the declaration would be
read as ~i32~ at every use and nothing would ever say so. Silence was the one
unacceptable answer; refusing costs a rename in the program that meant it.
Nothing was added for ~(defvar int 5)~ or ~(defn f [int x] ...)~. Identity
settles them: whatever those do with ~i32~ written in, they now do with
~int~, and both were already refusals.
** Widening
No table entry, because there is nothing to widen — ~int~ *is* ~i32~. Pinned
as identity instead: ~(+ intvar i64var)~ is refused with "expected i64, found
i32", the same message and the same spelling as ~(+ i32var i64var)~. Written
that way so it survives whatever the widening lane lands: it asserts that the
two spellings behave alike, not what either one does.
** Pinned
Type position (parameter and return), cast head, ~(Vec int)~ and
~(Map int float)~, struct fields, ~(defalias Row (Vec int))~, the
three-element ~defvar~ zeroed static, ~int~/~i32~ and ~float~/~f32~ passing
for each other across a call, the two erasure messages, the widening
identity, both no-op redefinitions, three redefinition refusals, and
~integer~, ~double~ and ~long~ still teaching. ~test/programs/int-float.flan~
runs the value half on both backends.
One existing row changed: the foreign-spelling pin in test_flan.ml used
~int~, which resolves now, and was moved to ~long~.
* A macro's parameter list, and the one breaking spelling, 2026-09-20
DISCUSS.org's "defmacro should support real parameter lists" is built.
=(defmacro do-grid [[r rows c cols] & body] ...)= — positional parameters, a
=[ ]= pattern wherever the argument is a vector, nesting, and =&= for the
tail. The list is read in lib/expand.ml (=params_of=, =check_call=), turned
into bindings by lib/parse.ml (=macro_body=) and checked against a call by
lib/macro.ml (=checked_call=) before anything is expanded.
** THE BREAKING CHANGE: [args] was the whole call, and is now the first argument
This is the one decision in the lane that changes what existing text means,
and it is here rather than in a commit message because it is the thing to
disagree with if it is wrong.
A macro's single parameter *was* the whole argument list, so =[args]= meant
"everything written at the call". Under a positional parameter list it cannot
keep meaning that: one named parameter has to be the first argument, the way
it is in every other language with parameter lists and the way Clojure has
it. So the whole list is now spelled =[& args]=.
The alternative was a legacy mode — one parameter with no =&= keeps the old
meaning — and it was refused. It makes =[a]= and =[a b]= mean unrelated
things, which is the kind of rule nobody can hold in their head, and it
would have left the corpus written in a grammar the documentation no longer
describes.
So every =defmacro= in the tree was migrated in the same commit. Seventeen
files, mechanical, bodies untouched:
- lib/prelude.ml — =clamp=, =unless=, =into=, and the dogfood batch's five
(=comment=, =inc=, =dec=, =++=, =--=), which landed on dev-loop after this
lane branched and were migrated at the merge — eight in all
- vendor/raylib/modes.flan — =with-drawing=, =with-mode-2d=, =with-mode-3d=,
=with-texture-mode=, =with-scissor-mode=
- vendor/edn/provide.flan — =defedn=; vendor/json/provide.flan — =defjson=
- test/programs/ — macros.flan (7), macro-cycle.flan (2), macro-spin.flan,
pkg-macro.flan, printers.flan, pkgs/mac (6), pkgs/macring (2),
pkgs/macspin (1)
- test/test_dev.ml, test_flan.ml, test_repl.ml, test_session.ml and
emacs/test-flan.el — the =defmacro= fixtures written as strings
Nothing was rewritten to *use* the new grammar as part of the migration —
=with-mode-2d= is still =[& args]= picking its camera out by hand, and its
hand-written arity guard still says what it said. That was deliberate: the
migration had to be a spelling change or it proves nothing. The new grammar
is shown off in test/programs/macro-params.flan, which is its own program
beside macros.flan.
The equivalence is asserted rather than assumed. pkg-macro.flan declares
=tenfold= (=[& args]=, =(at args 0)=) and =tenfold-listed= (=[n]=) with the
same body, and test_session expands both and requires the same text.
** Map destructuring in a macro's parameter list — deferred, refused by name
=dmap= (lib/parse.ml) is ={:keys [x y]}= over a *struct*: it reads field
names off a declared type. A macro's argument is a =Form=, whose =Map= case
is a flat run of alternating forms with no field names anywhere in it. So
the pattern cannot be translated — it would have to be given a new meaning
(match a keyword key in the literal map written at the call? bind by
position?), and none of those is obviously the one somebody wants.
Vectors and =&= are the 95% case and are built. A map pattern in a macro's
parameter list is refused by name where it is written:
map destructuring is not implemented in a macro's parameter list — a
macro's argument is a Form, whose Map case is a flat run of alternating
forms with no fields to name. Take the form and pick it apart in the body
Pinned in test_flan.ml. Whoever wants it should decide what it means first.
Flagged rather than patched by that lane, and fixed by the one after it. The
diagnosis was right about the exception and one row off about where: the
losing ~ask~ is the ~abort~ that ends the row, not the ~describe~ poll that
opens it — every reproduction died on the line after ~flan: aborted at the
break loop~, four runs out of four.
*The claim those rows now make.* ~abort~ is the one verb that ends the
process answering it, and in a merged ~flan dev~ that process is the daemon.
flan_agent.c's listener writes its own ~ok~ and sets ~aborting~; the break
loop's next pass calls ~die_now~, which is ~_exit(134)~, from the *program*
thread — while the reply to the editor is still being composed on the *serve*
thread, out of ~Dev.abort~'s ~ok~. Nothing orders the two. So an abort that
did exactly what was asked comes back either as ~:status "ok"~ or as the
socket closing under the read, and both are the same outcome. Neither is the
assertion: what says the abort worked is the ~waitpid~ wait underneath it,
which every one of these rows already does. A new ~aborted~ helper in
~test_dev.ml~ answers ~None~ for the end that arrived as an exit, and the
three sites that abort a stopped or trapped program go through it — the break
row, the globals row and ~trap_park~. The fourth ~abort~ in the file is the
one a *running* program refuses; it ends nothing and was left alone, as was
~test_agent.ml~'s, which talks to the agent's own channel: the ~ok~ is on the
wire before ~aborting~ is set, so the exit cannot overtake it, and that
file's ~send~ reads to EOF and already swallows a ~Unix_error~ besides.
The ~describe~ poll that opens ~trap_park~ is guarded too, and with the other
answer: these traps park because ~flan_trap_hook~ is installed, and with no
hook ~rt_trap~ falls through to ~rt_die~ and the program takes the daemon
with it — so a socket closing *there* is the trap having ended the program
instead of stopping it, which is the failure that row already names. Closed
on the abort is a pass; closed on the poll is a FAIL with a reason. Neither
is a retry.
SIGPIPE is ignored in ~test_dev.ml~ for the watchdog's reason: with a daemon
that exits by design, a ~Wire.send~ into the socket it left behind would kill
the binary with no line saying why — the same silent shape, reached from the
write side.
Measured after: 22 runs of ~test_dev.exe~, all exit 0, no fatal exception; 5
runs of ~dune test --root . --force~, all exit 0. ~--force~ because dune
caches a test that passed, and a cached pass proves nothing about a race.
* The two byte fills, 2026-09-20
DISCUSS.org's "a DEADBEEF-style sentinel-fill builtin", built. The author's
answer to the single-byte-or-four-byte question was "why not both? we need
some sort of memset -1 right? and dead-beef can loop, that's fine", so there
are two builtins and they are siblings of ~zeroed~, not a new shape.
Revised the same day: the pattern builtin was ~sentinel-filled~ and is now
~dead-beef~, and it gained an optional operand so the pattern is the
program's to choose. What did not change is ~filled~, or the fill boundary,
or the byte-order rule — the revision generalised the pattern, it did not
reopen what may be filled.
** The spellings
~(filled BYTE)~ and ~(dead-beef)~ / ~(dead-beef PATTERN)~, all value forms
driven by the type expected of them, exactly as ~(zeroed)~ is:
: (set grid (filled 0xFF))
: (set frame (dead-beef))
: (set frame (dead-beef 0xBAADF00D))
A place-taking ~(filled place byte)~ was the other candidate and was not
taken. ~zeroed~ already answers "the all-bytes-X value of whatever this is
being stored into", ~set~ already takes the place, and a second spelling for
an operation ~set~ expresses would have been a second thing to learn for
nothing. The cost is real and is paid on purpose: a fill in a position that
expects no type is refused ("filled needs to know the type it is filling"),
which is ~zeroed~'s own refusal worn by both siblings.
~dead-beef~ takes the pattern or leaves it out, and leaving it out is
*defined as* writing the default: the checker's zero-argument arm builds the
same ~Tast.Int 0xDEADBEEF~ the spelled-out call would have, so ~(dead-beef)~
and ~(dead-beef 0xDEADBEEF)~ are the same IR node by construction and no
backend has a second path for the bare form. An acceptance row prints both
and pins that they agree.
The pattern is an ordinary ~u32~ /expression/, not a literal — the byte arm's
rule at four times the width. A literal out of range meets ~in_range~'s
located "does not fit in u32"; anything computed is guaranteed by its type
instead, since a ~u32~ cannot be out of ~u32~ range. Refusing a computed one
would have been a restriction with no mechanism behind it: neither backend
needs the number early.
/Reading note, for whoever reviews this./ The revision asked for "a u32-range
constant ... decide literal-only vs any constant expression from what the
byte-fill arm already accepts". The byte-fill arm accepts any ~u8~
expression, runtime ones included, and the same instruction asked that both
backends handle "eax loaded from a value, not an immediate" — which only
exists if a computed pattern is legal. So the operand is any ~u32~
expression. That is a strict superset of constants-only: every program the
narrower reading allows behaves identically here. Tighten it to literals if
that was the intent; nothing else depends on the breadth.
** The fill boundary — what may be overwritten with raw bytes
*Numbers, and structs and fixed arrays built out of numbers. Nothing else.*
~Check.unfillable~ is the rule, in one recursive walk, and every refusal
names the type it stopped at and why.
Zero is a value every type can have; 0xDE is not. That is the whole of why
this rule exists and ~zeroed~ needs none:
- *dyn* — a struct holding a dyn is rooted on the collector's root stack with
a descriptor naming that word's byte offset. A filled one is a root
pointing at nothing and the next collection follows it. This is the refusal
the feature could not ship without.
- *Vec, Map, Allocator* — an owning header: pointer, length, capacity,
allocator. A filled one frees a wild address the first time it is touched.
- *string, slice* — a pointer and a length that every bounds check believes.
- *Ptr* — not walked by the collector, and a poisoned pointer is arguably the
useful case. Kept out anyway so the rule is one sentence rather than "plain
data, except one kind of address". *This is the arm to relax first if the
question is reopened.*
- *bool* — the one refusal that is about the backends rather than the
runtime. A bool is a byte in memory and an ~i1~ to LLVM, which reads the
low bit, where x86 compares the whole byte against zero: 0xDE is false on
one and true on the other. Byte-identical behaviour across the two backends
is the property this feature is pinned on, so the divergence is refused
rather than documented.
- *a data type* — a tag that names a case, and no byte pattern names a real
one. *An ~(Option T)~* — the same, one bit of it: a filled tag says the
value is there over a payload nobody wrote. *An enum* — its values are the
members it declared, and no byte pattern is one of them.
- *a union* — and this one is not about a tag, because ~env.unions~ is "the
untagged unions". It is that a union's members overlay and ~unfillable~
walks a struct's fields rather than a union's members, so nothing has
shown every member is plain data; a member that is not would be filled
through the one that is. Relaxable by walking the members, if anyone wants
it.
- *a function value* — a code address, and a call through a filled one jumps
into whatever the pattern happens to address.
Floats are in: every bit pattern is a float, NaNs included, and both backends
move one as bytes.
A ~defconst~ of a fill is refused by the existing constant rule and not by
anything of this feature's own — a fill is never a value the linker can write
into the image. A ~defvar~ is fine and goes through the startup function on
both backends, which ~programs/fill.flan~ pins.
** The byte order, which is the specification
*A pattern's ascending bytes are its big-endian bytes* — exactly how the hex
literal reads left to right. So ~(dead-beef)~ lays down DE AD BE EF and ~xxd~
reads "deadbeef"; ~(dead-beef 0xBAADF00D)~ lays down BA AD F0 0D. One rule,
both arities.
On a little-endian machine the word a 4-byte store must therefore leave is
the *byte reversal* of the pattern, which is all ~Emit.word_of_pattern~ is
(~bytes_of_pattern~ beside it is the ascending list). Those two are the one
place the order is written, and ~Tast.dead_beef_default~ is the one place
0xDEADBEEF is written, so the default and the parameterised case cannot
drift.
x86.ml reads ~word_of_pattern~ out of Emit rather than repeating it. It does
*not* use ~bytes_of_pattern~: its tail walks the bytes out of ~rax~ with
~shr~, which is the same arithmetic the list encodes and is how the computed
path has to do it anyway, so there was no second constant to share. emit.ml
uses both — the list for a folded tail, the word for the loop.
A literal pattern is reversed at compile time and reaches the loop as an
immediate — the default's generated code is exactly what it was before the
pattern became an operand. A computed one is evaluated and reversed at run
time, by ~llvm.bswap.i32~ on one backend and ~bswap eax~ (0F C8, new) on the
other.
*Tail behaviour.* A size that is not a multiple of four ends on a prefix of
the ascending bytes: 1 byte over is DE, 2 is DE AD, 3 is DE AD BE.
Equivalently, tail byte k is ~(word >> 8k) & 0xFF~ — which is what the
computed path actually does, by shifting, since there is no constant to fold.
~programs/fill.flan~ has all four lengths (8, 9, 6, 7) and, crucially, runs a
computed pattern over lengths 6 and 7: that is the case a constant-only
implementation would pass by accident.
** The backends
- *LLVM (emit.ml).* The byte fill is one ~llvm.memset~ with the byte as an
operand instead of a zero — the same call the existing bulk zero makes, and
the reason the single-byte fill is the cheap one. The pattern fill cannot
be a memset at all (the intrinsic takes one repeated i8, which is the snag
DISCUSS.org named), so it is a counted loop over dwords in the
header/body/exit shape ~emit_while~ writes, with the counter as an
entry-block alloca that ~mem2reg~ promotes. Every store is ~align 1~,
because a ~[7 u8]~ array is a legal thing to fill. A computed pattern goes
through ~llvm.bswap.i32~ (newly declared) and the loop stores an SSA value
rather than a constant; the tail then shifts and truncates.
- *x86 (x86.ml).* ~rep stosb~ for the byte fill — ~zero_loc~'s three
registers with the program's byte in ~al~ instead of a zero — and ~rep
stosd~ (new, 0xf3 0xab) for the pattern, with the stored word in ~eax~. A
computed pattern is loaded and run through ~bswap~ (new, 0F C8); the tail
walks the bytes out of ~rax~ with ~shr~ by an immediate (new, C1 /5), which
is used rather than ~shift_cl~ precisely because ~rep stosd~ leaves ~rcx~
at zero. Either operand is evaluated *before* ~rdi~ is loaded, because
evaluating one may call and a call clobbers ~rdi~; ~rep stosd~ does not
touch ~rax~, which is what lets the tail keep reading the word out of it.
- The one asymmetry: ~emit.ml~ needs a ~Tast.Set~ arm of its own to fill the
place rather than a temporary, because its value path returns an SSA value.
~x86.ml~ needs none — a ~Set~ there already lowers its value into the
place's location, so filling a place and filling a temporary are the same
line.
- *js.ml* refuses both by name. A struct is an object there, not a run of
bytes, so there is nothing for 0xFF to mean.
** What was run
~dune test --root .~ green (exit 0, no FAIL lines). Three acceptance rows
over ~test/programs/fill.flan~ — default, ~-O0~ and ~--x86~ — and the three
outputs diffed against each other by hand before the rows were written:
byte-identical, with a fourth build (~--dev~) added at the rename: four-way
identical. The three rows were confirmed to actually run, by breaking one
expectation on purpose and watching all three report. Seventeen checker rows
in ~test_flan.ml~: four accepting (both ~dead-beef~ arities and a computed
pattern among them), and seventeen refusals covering the boundary — one per
reason, since review found the tagged types were sharing a line that was
false for two of them — both arities, both no-expected-type positions, the
byte's range, the pattern's range and the ~defconst~ rule.
~dune test~ exits 1 on this branch about half the time, with *no FAIL line
anywhere* — the ~Flan.Wire.Closed~ flake an earlier lane wrote up further up
this file. Green runs are real (2 of the last 4 exit 0); the rest are that
race.
*This lane makes it fire more often, and that is worth saying plainly rather
than filing the whole thing under "known flake".* Measured, because early
runs looked like the lane had broken something:
| what | full ~dune test~ |
| base commit 1526b6f, none of the lane | 0 failures in 5 |
| this lane | 5 failures in 5 |
| this lane, my 3 acceptance rows off | 1 failure in 3 |
Isolated, ~test_dev.exe~ alone (15 seconds, not the ten-minute suite) gives 4
in 6 here against 2 in 6 at the base — much closer, which is the shape you
would expect if the lane is not touching the racy code but *is* changing the
load around it. The three acceptance rows add three compile-and-run jobs to
the pool that ~test_dev~ runs alongside, and a busier machine is slower to
answer the poll that races.
So: not a new defect, and nothing in ~check.ml~/~emit.ml~/~x86.ml~ here is
implicated — but the next lane to add acceptance rows will push the rate up
again, and the fix the earlier writeup already named (catch ~Closed~ in
~trap_park~'s poll and read it as the program having ended) is now worth
doing rather than noting.
One detail to add to that earlier writeup, which had only seen the flake on
~dev-trap-null-alloc~: it is not row-specific. Five of my six isolated
failures were that row and the sixth was ~dev-trap-free-all~, so what is racy
is ~trap_park~ itself and every row that calls it — which is exactly what the
mechanism described there predicts. Per the sweep policy the ~@x86~ and
~@sanitize~ sweeps were not run here.
* Shadowing a builtin, 2026-09-20
The author's decision, in the author's words:
#+begin_quote
"allow shadowing but warn" — a user ~(defn get ...)~ colliding with a builtin
is legal, the USER'S definition wins at call sites (real shadowing, Clojure's
model: the def takes over, a warning says so), and the compiler warns once at
the definition site.
#+end_quote
** Where builtin-wins actually lived
Not in a table and not in a precedence list. ~named_call~ is one
~match name with~ whose arms are the builtin names written out as string
literals, and the three arms that look anything up — a local of ~Fn~ type,
~gsigs~, then ~env.fns~ — are the last three in that match. So a builtin won
because OCaml tried its arm first, and for no other reason. ~env.fns~ never
outranked anything; it was simply never reached for a name spelled like a
builtin. The old comment above ~arity~ said this outright ("the dispatch
above reaches every builtin arm before it ever looks in [fns]") and is the
only place it was written down.
** The resolution change
One guard, first arm of ~named_call~:
: | _ when shadows_builtin ctx loc name -> ordinary_call ctx ~want loc name args
and the three trailing arms factored into ~ordinary_call~ so that both routes
— falling past every builtin, and being sent straight there by the guard —
resolve a name by exactly the same rules. Order is now total and reads the
way a reader would guess: local of function type, then generic signature,
then the function table, then the builtins, then the struct and the
did-you-mean refusals.
~shadows_builtin~ asks two questions, in this order. Is the name a
builtin's: one lookup in ~builtin_set~, false for every call to an ordinary
function, and asking it first is also what keeps the arms that are not calls
— an enum cast, a cast to a type variable, a machine-type cast — exactly
where they were. Then, and only then, is there a definition that reaches
this call: a local of function type, or a defn written in this same file.
~builtin_set~ is a ~Hashtbl~ and is new. The guard is the first arm of the
dispatch, so it runs at every named call, and the list ~builtin_names~ that
already existed is walked linearly — about a third of check time on a
program of twenty thousand calls, measured in review. The list stays for the
did-you-mean, whose order is its order; the set answers the membership.
** The warning, verbatim
: shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition — the builtin stays reachable as builtin/get
: 20 | (defn get [p P] i32 (.x p))
: | ~~~
The clause after the second dash arrived a day later with the escape hatch
itself; this entry shipped without it, because there was nothing to name.
Rendered by ~Loc.entry ~mark:'~' ~label:"warning: "~, which is the
~--warn-memory~ precedent, so flycheck parses it exactly as it parses an
error. Nothing raises and the exit status does not move. Unlike
~--warn-memory~ it is behind no flag: there is nothing to tune, and the line
is one line and rare.
It is printed from ~Check.build_program~ rather than from ~bin/main.ml~
beside ~print_memory_warnings~, because every route into the compiler passes
through that function — build, check, run, and the dev daemon's reload, which
is where a defn is most likely to be written. The list itself is
~Check.shadowed_builtins~, a pure function over the declarations, which is
what the tests ask.
** Scope, settled from the code
*Package-wide or program-wide: neither, and the mechanism already decided
it.* ~Load~ qualifies every name an imported package declares to ~alias/name~,
including its own uses of them, so a package's ~get~ is ~rl/get~ and cannot
collide with a builtin at all. What is left is the other direction: a program
that defines ~get~ and imports a package whose body calls the builtin ~get~.
That call must keep meaning the builtin, and it does: the shadow reaches
exactly the file the definition was written in, which is the same visibility
a defn has everywhere else. The prelude falls out of the same rule rather
than needing one of its own — it is a file, and not the one the program is
in.
The file and not the enclosing function's name, which is what this first
shipped with and was wrong. A package's functions are qualified at the
import, so "does the owner's name carry a slash" answers correctly wherever
a call sits inside a function — and wrongly in the one place a call does
not. Review demonstrated it: a program defining ~(defn len ...)~ reached
inside an imported package's ~(defvar sz i32 (len "abcd"))~, which is
checked with no owner at all, and made it 999. A global initialiser has no
enclosing name; it does have a file.
~programs/shadow-builtin.flan~ is every half in one program: 7 is the
program's own one-argument ~(get p)~, 4 is the builtin ~get~ called inside
the package it imports, 99 is a shadowed ~+~, 999 is the program's own
~len~, and the last 4 is that same ~len~ inside the package's global
initialiser, where the builtin still means the builtin.
*Prelude macros.* No rule was needed: the namespace is already one.
~(defn comment [x i32] i32 ...)~ against the prelude's ~(defmacro comment
...)~ is refused today as "comment is defined twice", with a note at the
prelude's definition, and the same for ~inc~ and ~dec~. Shadowing a builtin
is a different question precisely because a builtin is not a declaration —
it is an arm in the compiler, with nothing for a redefinition check to point
at. Macros expand before checking and key on the head name unconditionally,
so if the redefinition check were ever relaxed the macro would win and the
defn would be unreachable; that is not a state this compiler can reach, and
nothing was written to handle it.
*What the file rule costs.* A bare REPL expression — ~C-x C-e~ on a form,
evaluated with origin ~<eval>~ and no file behind it — is not the file the
defn was written in, so it reaches the builtin. ~C-c C-c~ sends the buffer's
own path and is unaffected, which is the case the dev loop is actually made
of. It is the conservative direction: a REPL line meaning the builtin is a
surprise, a REPL line silently meaning a definition somewhere else is a
worse one. If it ever bites, the fix is for the session to evaluate with the
buffer's path as origin, which it already knows.
*A macro named after a builtin warns too, and that is right.* ~(defmacro get
[args] ...)~ is an ~Ast.Defn~ like any other by the time the declaration
list is collected — a macro is a function the compiler runs — so
~shadowed_builtins~ names it and the warning reads the same. The macro also
wins, and by a different mechanism: expansion runs before checking and keys
on the head name, so the call never becomes a call at all. The one wrinkle
is that a file carrying macros is checked twice, the macro module first, so
its warning is printed twice. Disclosed rather than suppressed: dropping a
duplicate means keeping state across the two checks, and the second line is
the same line.
*The dead end: a shadowed builtin has no remaining spelling.* Nothing in
this language qualifies a name — there is no ~core/get~, no ~(builtin get)~
— so a file that defines ~get~ has given up the builtin ~get~ for the whole
file, and a definition that wants to *wrap* the builtin cannot. ~(defn len
[s string] i32 (+ 1 (len s)))~ is not a wrapper, it is unbounded recursion:
the inner call reaches the definition being written, and the program
stack-overflows at run time with no diagnostic from the compiler, which has
nothing to object to. The warning says the name is taken over; it does not
say this. An escape hatch is a language decision and is with the author.
/Closed the next day./ The author's answer was the qualified spelling — see
"builtin/, the reserved qualifier, 2026-09-20" below. ~(defn len [s string]
i32 (builtin/+ 1 (builtin/len s)))~ is the wrapper this paragraph said could
not be written, and it runs: ~programs/builtin-qualified.flan~ prints 5 for
it beside the builtin's own 4. The warning's sentence now carries the escape,
so the reader is told what is left at the moment they are told the name was
taken over.
** Pins
- ~test_flan.ml~: the warning's kind, line and column; its message, matched
whole and not by needle; that it carries no notes; that the source which
used to be refused now checks; and that a program shadowing nothing warns
not at all.
- ~test_flan.ml~, from review: a shadowed operator warns with the same
sentence and lowers to a ~Call~ to the definition rather than the ~Add~
prim; and a call read with another file's name, against the same
declaration list, reaches the builtin and is refused at the builtin's
arity — the global-initialiser case at its smallest.
- ~test_acceptance.ml~: ~programs/shadow-builtin.flan~ outputs
~7\n4\n99\n999\n4\n~, and the ~@x86~ sweep compares both backends over
the same file.
- Removed: the ~check/builtin-arity~ kind, its message ("this is the builtin
get, which a defn of the same name does not replace"), its note ("is also
defined here, and this call is not reaching it — rename it to call it"),
and the three checks that pinned them. The situation cannot arise: the call
reaches the user's defn, whose arity is whatever it declared.
- Changed: the builtin-arm/~Check.builtins~ cross-check reads ~named_call~'s
source down to ~ | _ ->~ rather than ~ | _~, because the new first arm is
guarded and stopping at it read the whole region as empty.
** One thing the new package cost
A package under ~test/programs/pkgs/~ needs a ~glob_files~ line of its own in
four places in ~test/dune~ — the test stanza and the ~@valgrind~, ~@x86~ and
~@js~ sweeps — because dune's glob does not descend and the sweeps walk
~programs/*.flan~ whole. Without it the corpus row fails with "no package
at ..." and prints no FAIL line, only "1 failure(s)" at the end of the log:
worth knowing, because a grep for FAIL says green over it.
** What was run
~dune test --root .~ in the lane's worktree, forced: exit 0. Rebased onto
dev-loop before the review follow-ups, so the ~arity~ signature this lane
cuts down is the one the byte-fill lane had just given a ~ctx~ argument, and
the ~int~/~float~ section's paragraph about "the ~arity~ precedent, where
the builtin wins" is revised in place — that precedent is what this lane
deleted.
The heavy sweeps (~@x86~, ~@sanitize~, ~@valgrind~) were left to the batch.
* Lazy instance migration for a redefined defclass, 2026-09-20
CLHS 4.3.6 — the ~update-instance-for-redefined-class~ protocol — adapted to
the dyn side's classes, minus the user hook. Redefining a ~defclass~ in the
dev session used to be *silent*: a class is compile-time sugar for a
constructor ~defn~, so the edit replaced a function body, the instances
already in the program kept their old keys for ever, and nothing anywhere
said so. Now the instances follow the class.
The research is ~docs/SBCL-REDEFINITION-NOTES.md~, candidate C. Its central
finding is why this was cheap and why the same thing is not available for a
typed ~defstruct~: every SBCL mechanism of this kind rests on an instance
carrying a pointer to its shape, and a dyn instance *has a header* where a
flat struct does not.
** What it does
#+begin_src lisp
(defclass point [x y])
;; ... a program runs, builds instances, holds them in globals ...
(defclass point [x z]) ; C-c C-c, with the file's callers if any
;; every live instance, at its next touch:
;; :x keeps the value it had (matched by name)
;; :z appears as nil (gained)
;; :y is gone (dropped)
;; the object is the same object (identity preserved)
;; (class-of p) is still :point (so every method still reaches it)
#+end_src
Nothing is enumerated and no heap is walked, which is the part the old
deferral thought was missing. The redefinition is O(1) — one registry entry
updated — and the work is paid per instance, once, by whoever touches it.
** The three pieces
*** A registry, in the runtime
~runtime/flan_dyn.c~, under "Classes": one entry per class name, holding the
current slot list and a generation counter. ~flan_dyn_class_def(name, slots,
n)~ registers or re-registers one; ~slots~ is the names packed into a single
string with newlines between.
*Nothing in it is a collector object, and that is the whole GC argument.* A
class's name and its slots are interned ~kw_entry~ pointers — immortal, not
on the collected heap, never traced — which is the same argument the ~klass~
header field already makes. The table itself is ~malloc~ed, append-only and
never freed. So no root is pushed for the registry, the marker has nothing to
reach in it, and a collection triggered from inside a migration cannot see a
half-built slot list. A registry of dyn vectors would have needed all three
of those worried about.
*** A generation, in the instance's header
A ~uint32_t~ in ~flan_obj~, *in the padding between ~mark~ and ~len~*.
~sizeof(flan_obj)~ is 48 with it and was 48 without it — the union is exactly
24 bytes (~items~, ~cap~, ~klass~), so there is no spare word inside the arm
and a field placed after the union would have cost eight bytes on every dyn
value in the heap for a word only class instances read. The obvious guess
before reading the struct is that ~view.is_vec~ leaves four spare bytes at
offset 44; it does not. That word is the *view* arm's and is aliased with
~klass~ — the arms overlap, so nothing inside the union is free. The free
bytes are the ones alignment already wastes, in front of it.
The number is asserted rather than commented: ~flan_dyn_obj_size()~ is a new
entry point and ~dyn_ops.c~'s ~classes~ mode checks it against 48, so a later
field that pushes it out fails a test instead of costing that silently.
Zero means "built before any definition was registered", which is every
instance of every program that was built and never reloaded. The first
registration of a name lands on 1, so those instances migrate exactly once,
the first time the class is redefined under them — which is what makes a
program that predates this correct rather than merely unbroken.
*** A registration thunk, per reload
~lib/session.ml~'s ~change~ emits one nullary function per evaluation that
declared any class, calling ~flan_dyn_class_def~ once per class, and hands it
to ~Emit.redefinition~/~X86.redefinition~ as ~?call~ — the mechanism ~C-x
C-e~ already uses, where the agent finds ~flan_reload_call~ by ~dlsym~ and
runs it after the module's bodies are published and on the game thread. Both
backends, unchanged: the thunk is an ordinary Tast function and the backends
lower ~Rt~ calls generically.
*It has to be a thunk and not something in the constructor.* The case this
exists for is a class redefined and *not* constructed — old instances touched
after the edit — and a registration that only ran at construction would never
fire for it. That is the same reasoning that rules out registering from
~main~: reload modules re-execute their definitions, not their program.
*Every* class in the form is registered, not only the ones whose slots
changed, because a class the registry has never seen has to arrive somehow.
The bump is what is conditional: re-registering an identical list changes
nothing, so a ~C-c C-k~ costs one comparison per class and migrates nothing.
Without that rule every save would migrate every instance in the program.
** Where a migration happens
~want_map~ (so ~get~, ~put~ and ~has-key?~), ~flan_dyn_len~'s map arm, and
~dyn_equal~'s. CLHS asks for "no later than the next time a slot of that
instance is read or written"; those are the three places that read or write
the slot *set*.
*Neither printer is one of them*, and that has a consequence somebody will
meet. ~render~ — which ~print~ goes through, and which the editor renders
every dyn value with — and ~say_render~ — the 96-byte sentence a trap
prints — both walk the entries raw and neither syncs. ~say_render~ runs
inside trap reporting, where the heap is whatever the trap left, and a
printer that frees an object's entry block and installs another is not
something to have on that path; ~render~ is its sibling and is reached from
it for nested values, so splitting them would put the mutation one recursion
below a trap anyway.
So: *a stale instance shows its old slots to the editor until something
touches it.* A watch expression, the value ~C-x C-e~ answers and the
inspector's render of a dyn all arrive through ~render~, so in the moment
after a ~defclass~ is redefined the inspector can show a slot the class no
longer has and omit one it has gained — while ~(get p :z)~ typed at the same
instant answers the new definition, migrates the instance, and makes the
inspector agree from then on. CLHS's "implementation-dependent time" permits
it; it is the price of the printer staying a printer; and it is disclosed
here rather than discovered.
The migration rebuilds the entry block rather than compacting it in place,
and writes the slots in the *class's* order. One ~malloc~ per instance per
redefinition, and the property bought is that a migrated instance is
indistinguishable from a freshly constructed one — ~dyn_equal~ compares maps
by lookup and would not have cared, but ~len~ and ~render~ work in insertion
order and would have.
** Equality across generations: migrate first
Two instances of one class built either side of a redefinition, holding equal
values for the slots the class still has, *are equal*. ~dyn_equal~ migrates
both operands before comparing the tag or the length. The decision recorded:
equality is over the class as it is now, not over the shapes the two values
happened to be born with. The alternative — comparing key sets literally —
would answer "not equal" about a difference the class no longer has, and
would make the answer depend on which of the two had been touched since.
** The registry is advisory, and this is the honest cost
A class instance is an open map. ~put~ takes any key — FIX.org already defers
refusing ~(get p :z)~ — so a program can write a key the class never
declared, and the next migration *drops it*, because the migration's rule is
that an instance's keys are the class's slots.
That is data loss, and there is no enforcement behind it to make the loss
impossible. Enforcing would mean refusing an unknown key at ~put~, which is
the static slot discipline the dyn side deliberately does not have, and the
research names this exact risk: "if ~put~ of an arbitrary key stays legal,
the registry describes an intention rather than a constraint". It describes
an intention. ~test_dev.ml~ pins the loss as behaviour rather than leaving it
to be discovered.
** What the session had to give up to allow it, and what it kept
A slot added or removed is a *constructor signature change*, which
~session.ml~'s ~compatible~ refuses by default — a call site compiled to pass
two dyn words into a three-parameter body leaves the third holding a
register, and a dyn word that is not a value is a wild pointer rather than a
wrong answer. Item 6's own line above — the constructor "is an ordinary
~defn~ — so its arity refusal, its cell in a dev build and its behaviour
under redefinition are the ones every function already has" — is true and
was read one step too far: what every function already has *is* the signature
refusal, so a class could not change its slots at all. That was the first
thing this lane had to fix, before any of the runtime work could be reached.
The refusal is now lifted for a ~defclass~ constructor *and nothing else*,
and only when no compiled caller is left behind. In practice the checker gets
there first: the whole declaration list is re-checked against the new
constructor before ~compatible~ is consulted, so a declaration still calling
it with the old count is refused at the call site with a line number — which
is the sentence a reader sees, and is what ~test_session.ml~ pins. What
~change~ adds is the *reason* held locally rather than inherited: a caller
that type-checks under the new arity is one whose source changed, so it is in
this form and is republished with the class. The walk over ~t.program~'s
bodies asserts that instead of assuming it, and if it ever fires the answer
is a refusal naming the callers rather than a wild pointer.
Not touched: typed ~defstruct~ layout changes and typed global type changes
keep their refusals. ~SBCL-REDEFINITION-NOTES.md~ §5 is why — a flat unboxed
struct has no header to stamp and cannot change size in place, so none of
this is available there at any price.
** Deferred, with the reason
- *~update-instance-for-redefined-class~ itself*, the user hook. CLOS hands
the discarded slots' values to a method so a coordinate change can be
written by hand; the obvious Flan spelling is a generic,
~(defmethod update-for-redefined point [p added discarded] ...)~, riding
the dispatch that already exists. Left out of v1 because the automatic
half — name matching — is the half that makes redefinition usable, and the
hook is what makes it *expressive*. Nothing about the design blocks it:
the migration already computes both lists.
- *Initargs validation.* CLOS's default method signals on an initarg the
class does not declare. There are no initargs here; construction is
positional.
- *Refusing an unknown ~put~*, which is what would turn the registry from
advisory into enforcing. Same gate as the deferred ~(get p :z)~ check.
- *Rolling a failed migration back.* SBCL wraps the user hook in
~nlx-protect~ so a signalling method leaves the instance on its old
wrapper. With no user hook the migration cannot signal, so there is nothing
to roll back yet; it becomes a real question the day the hook lands.
- *The whole-program build registers nothing.* A program that is built and
never reloaded has no registry at all, its instances carry generation zero,
and everything behaves exactly as it did before this existed. Registering
at startup would need an initialiser in both backends' executable paths and
buys only introspection — there is no *stale* instance in a program whose
classes never changed.
** Pinned
- ~test/dyn_ops.c~'s ~classes~ mode, run by ~test_dyn.ml~: the object size,
an unregistered class behaving as before, a slot gained, a slot lost, both
at once, three definitions an instance slept through, a re-registration of
the same list migrating nothing, two generations compared, a plain map
untouched by any of it, and two thousand instances migrated while the
collector runs. Driven from C because the event has no Flan spelling: a
class definition changes between two *modules*, so no single program can
see one change.
- ~test_sanitize.ml~'s ~dyn_sweep~ runs that mode under ASan and UBSan. It is
the one mode that frees an object's entry block while the object stays live
and reachable, which is the shape a wrong marker would show as a
use-after-free and as nothing at all in the checked build.
- ~test_dev.ml~, "a class redefined under its own instances": a real daemon
over ~test/programs/dev-classes.flan~, instances pushed into a dyn global
by ~C-x C-e~ thunks, then five ~C-c C-c~ evaluations of the class — four
of which change the slot list — with the program's own heap answering
between them: gained slot nil, kept slot kept, count right, *a generic
still dispatching after the migration*, an untouched instance migrating on
its own first touch, a lost slot gone, the third generation, the tag
surviving, the one unchanged re-evaluation migrating nothing, and a
raw-~put~ key dropped by the next real redefinition.
- And the same protocol once more against a ~flan dev --llvm~ daemon. The
block above runs on x86, which is what ~flan dev~ takes unasked; the subset
under LLVM is the part that is backend-specific — whether the registration
thunk reaches the runtime at all — and everything past that point is
flan_dyn.c's, which does not know who called it. Written because
~x86.ml~'s header had claimed for some time that it did *not* emit
~flan_reload_call~, which is exactly the kind of sentence not to trust
twice.
- ~test_session.ml~: a slot added and a slot removed both accepted, the
module carrying a ~call~ to ~flan_dyn_class_def~, a definition of
~flan_reload_call~ and the packed slot-list constant, an unchanged class
registering anyway with its own list, the refusal when a
compiled caller is in the way, and the same edit accepted when the caller
comes with it.
** Found on the way
~lib/x86.ml~'s ~redefinition~ header said "the transient ~flan_reload_call~
thunk is not built here, and is refused by name". It has been built there for
some time — the code is at the bottom of the same function — and the comment
had simply not moved with it. Corrected rather than worked around; this
lane's thunk goes through that path on every ~C-c C-c~ of a class, which is
the default backend for ~flan dev~.
A second one, found by the review rather than by the lane: two of the
~test_session.ml~ pins above asserted the string ~flan_dyn_class_def~ against
the module's IR text, and ~emit.ml~ writes a ~declare~ for every runtime
entry point into every module it emits — so both passed against a module that
registered nothing. They assert ~call void @flan_dyn_class_def~ and the packed
slot-list constant now. Confirmed by mutation: with the thunk suppressed the
old needles pass and the new ones fail. Worth carrying as a habit rather than
as a fix — a needle that names a runtime symbol is matching the declare block
unless it says ~call~.
** What was run
~dune test --root .~ green (exit 0, no FAIL lines) before and after the
rebase onto dev-loop, and ~test_dev.exe~ run directly afterwards because its
label can be swallowed by a cached run. ~dune build --root . @sanitize~ clean
on the committed source, which is where the two-thousand-instance migration
under collection actually gets looked at.
The rebase is worth a line of its own. Three conflicts were additive —
FIX.org, ~want_map~ (the diagnostics lane gave ~trap2~ a location pair, this
one put a ~class_sync~ beside it, both wanted), and ~test_dev.ml~'s
agent-socket block beside this one's. The fourth was not a conflict at all
and is the one to remember: ~flan_dyn_class_def~'s argument check was written
against the four-argument ~trap1~ and merged clean into a tree where ~trap1~
takes a location first, so the class name would have been read as a length.
*~dune build~ does not compile ~flan_dyn.c~* — it is a string the compiler
carries and hands to clang at ~flan run~ — so a green build is not evidence
about that file at all. ~dune test~ is, and so is running any program.
* Implicit widening, 2026-09-20 — "go with C"
Answers DISCUSS.org's *implicit numeric conversions with a warning flag,
instead of hard errors*. The ask there was a warn-instead-of-refuse mode; the
answer is narrower and needs no mode and no flag.
*The decision.* Implicit numeric *widening* is legal — every conversion that
cannot change the number. *Narrowing stays a hard error everywhere*, with no
flag that turns it into a warning. Odin's position roughly; Rust's
no-conversions-at-all position is rejected, and so is C's, which is what
DISCUSS.org's ~-Wconversion~ middle ground would have reproduced.
So there is no second type-checking mode, which was the objection in the note:
one predicate says which conversions exist, one helper inserts the ~Cast~ for
them, and everything else in the checker is unchanged.
** The lattice
~Types.widens_to ~from ~into~ (lib/types.ml). One rule decides every row: a
conversion is admitted exactly when no value of the source can come out the
other side as a different number.
| from | widens implicitly into |
|-------------+-------------------------------------------|
| ~i8~ | ~i16~ ~i32~ ~i64~ ~f32~ ~f64~ |
| ~i16~ | ~i32~ ~i64~ ~f32~ ~f64~ |
| ~i32~ | ~i64~ ~f64~ |
| ~i64~ | — (nothing) |
| ~u8~ | ~u16~ ~u32~ ~u64~ ~i16~ ~i32~ ~i64~ ~f32~ ~f64~ |
| ~u16~ | ~u32~ ~u64~ ~i32~ ~i64~ ~f32~ ~f64~ |
| ~u32~ | ~u64~ ~i64~ ~f64~ |
| ~u64~ | — (nothing) |
| ~f32~ | ~f64~ |
| ~f64~ | — (nothing) |
Read off the rule, one clause at a time:
- *Same signedness, strictly wider* — the uncontroversial half.
- *Unsigned into strictly wider signed* — ~u8~→~i16~, ~u32~→~i64~. Every
value of the source is a value of the target, so it is in.
- *Signed into unsigned* — never, at any width: the negatives have nowhere to
go.
- *Equal width across signedness* (~i32~→~u32~, ~u32~→~i32~) — never, for the
same reason. Half the range would have to move.
- *Integer into float, exact only.* An ~f64~ significand is 53 bits, so
everything 32 bits and under reaches it and ~i64~/~u64~ do not — 2^53+1 is
not an ~f64~. An ~f32~ significand is 24 bits, so only the 8- and 16-bit
integers reach it. Odin allows any integer into any float; this is the
tighter rule deliberately. A program that wants ~i64~→~f64~ writes ~(f64 x)~.
Loosening this later adds programs; tightening it later would break them,
which is why the loose version is not the one that landed.
- *~dyn~ is not in the lattice.* Crossing into and out of a box is
~box~/~unbox~ and is untouched — in particular a ~dyn~ still only unboxes to
~i64~/~f64~/~bool~, and a narrower want there is still the refusal
lib/check.ml's ~unbox~ has always given.
- *Containers are invariant.* A ~[i32]~ is not a ~[i64]~, a ~(Vec i32)~ is not
a ~(Vec i64)~, an ~[8 u8]~ is not an ~[8 u16]~. Widening rewrites a value
with a ~Cast~; there is no value to rewrite in a slice that does not own its
bytes, and rewriting a ~Vec~ would mean allocating a second one.
- ~bool~ and an ~Enum~ are not numbers and are not on the list. A keyword still
resolves against an enum and a bare integer still does not fit one.
*Not expressed as a loosening of ~equal~ or ~fits~*, deliberately.
~widens_to~ is a separate predicate precisely so that admitting a conversion
is always paired with inserting the ~Cast~ that performs it. Had ~fits~ been
loosened, every site that accepts a value without rewriting it would hand the
backends a node whose type lies about the bits it holds.
** Where it applies
~Check.expect~ (lib/check.ml) is the single place a wanted type meets a
produced one, so one arm there covers the whole surface: argument passing,
return position, ~let~ and ~defvar~ with an annotation, struct field
initialisers, ~Vec~ pushes, ~set!~, every C import's parameters. Nothing else
had to learn about widening except the binary operators, which have no
"wanted type" to meet.
** The join rule for binary operators
Both operands of a binary operator have one type, and the old comment said
"there is no implicit widening, so one side has to decide it". The
decides-rule generalises rather than disappearing:
1. *An expectation still wins, and it reaches the operands.* When the site
wants a type — ~(defn f [] i64 (+ a b))~ — that want is threaded into both
operands as before, and now widens them. The addition happens at ~i64~, not
at ~i32~ followed by a widened result. That is the better of the two and it
is only reachable by programs that did not compile before.
2. *Literals decide exactly as they did, and this one had to be defended.*
~y_decides~ and ~needs_want~ are untouched: a literal takes its width from
the other operand, a float literal outranks an integer one. ~(+ x 1)~ over a
~u64~ ~x~ still builds a ~u64~ one, which is what keeps
~(let [h fnv-offset])~ with a ~u64~ ~defconst~ meaning exactly what it
meant.
Saying so was not enough. The join is implemented as a *trial* — ask the
second operand for the first's type, and reconsider if it refuses — and the
first version of it reconsidered a literal too, which silently moved
~(+ u8-thing 300)~ from "300 does not fit in u8" to i32 arithmetic
answering 555, asymmetric in the operand order, and ~(+ i32-x 1.5)~ to an
f64 add. That is a different language from the one decided on. A literal
that does not fit is the program's mistake and not a pair of types that
failed to meet — the literal had no type of its own to bring — so the three
refusals that say so (~in_range~, and the integer and float literal arms of
~check~) now carry the kind ~check/literal-at-want~, and the trial re-raises
on sight of it rather than looking again. Pinned four ways: the literal as
the operand, the literal buried inside one, the float-literal spelling, and
a literal that *does* fit still taking the operand's type.
3. *Otherwise the wider side decides*~Types.join~: whichever operand the
other widens into, with the loser wrapped in a ~Cast~ to it. ~(+ i32-var
i64-var)~ is ~i64~ and is newly legal. ~(min i8-var i16-var)~ is ~i16~.
4. *Equal-width cross-sign still refuses.* ~(+ i32-var u32-var)~ has no join —
neither widens into the other — and the message names the cast to write.
~join~ is not a real lattice and is not meant to be: ~(i32, u32)~ has no
answer, and inventing ~i64~ for it would pick a type neither operand was
written at.
*Folds are still folds.* ~(+ a b c)~ is ~((a + b) + c)~, so the join is
pairwise and left-to-right: the first pair settles a type and the third
operand is checked against it. ~(+ i8 i8 i64)~ therefore still refuses, where
~(+ i64 i8 i8)~ passes. Left to stand rather than joined across the whole
argument list, because changing that would change what ~(- a b c)~ means, not
only what it admits.
*Shifts are carved out.* ~<<~ and ~>>~ do not take the plain join: the value
decides, and the count widens to the value's type. Under the general rule
~(<< u8-var i32-count)~ would widen the *value* to ~i32~ and the result type
and the wrap width would silently follow the count's declared type — and the
emitter's poison mask is keyed to the value's width. A count wider than the
value is refused and says so.
** Const folding is unchanged, and was never the thing it looked like
The ~defconst~ integer folder (~const_int~, lib/check.ml) runs on the *AST*,
before anything has a type, and carries one ~int64~ per constant with no width
attached. So it already folded across widths and still does —
~(defconst w i32 4)~ times ~(defconst h i64 5)~ has always been a constant 20,
usable as an array length — and widening neither added a fold nor removed one.
Measured, not assumed.
The one thing that did change is at the edges rather than in the folder: it
answers nothing for a ~Call~ whose operator is not one of the five arithmetic
names, and a written cast is such a call. So ~(* w (i64 h))~ was not a
constant and ~(* w h)~ is — which means dropping a cast that widening made
unnecessary can turn a run-time computation into an array length. That is
widening adding a program, the same as everywhere else, and needed no change
here.
** No overload resolution to disturb
Worth saying plainly, because widening is exactly the change that breaks
overloading in a language that has it: this one does not. Every builtin is
dispatched by *name* in ~named_call~ — there is no set of candidates to pick
between, so widening cannot change which one fires and cannot make a call
ambiguous. ~min~/~max~ and the arithmetic builtins looked like they keyed on
types, and what they actually do is check a predicate (~ordered?~,
~numeric?~) against the type the operands already agreed on. Widening changes
what they agree on and nothing about the dispatch.
** Sites changed, and sites kept
Changed, three of them and no more:
- lib/types.ml — ~widens_to~ and ~join~, new. ~equal~ and ~fits~ untouched.
- ~Check.expect~ — one arm, which is the entire annotation surface.
- ~Check.binary~ — the join, and ~~join:false~ for the shifts.
Kept, with the message saying *narrowing* rather than "no conversions":
- ~Check.unbox~'s per-width refusal at the dyn boundary. A dyn carries one
integer width and one float width, so there is no narrower source to widen
from and nothing on the lattice reaches it; what it refuses is a truncation
at the one boundary where the value's type was already uncertain, and that
is as true as it was.
- Every numeric refusal that survives ~expect~ now carries ~numeric_note~,
which tells the two surviving cases apart: a narrowing names the cast and
points out that the other direction is free, and an equal-width cross-signed
pair is told that neither direction exists.
Comments rewritten rather than left to rot, each now stating the new invariant
rather than the old one: lib/types.ml's header, ~equal~'s note (why widening
is deliberately *not* a loosening of it), ~Check.unbox~, ~Check.binary~, the
bitwise and shift arms, the ~embed~ two-spellings argument (which turns out
never to have rested on widening at all — it rests on containers not
converting), lib/prelude.ml's ~print~ note and both ~sum-~ notes,
docs/BUILT.md's ~gravity~ and ~#load~ paragraphs, test/programs/embed.flan,
and the ~+~, ~bit-and~, ~<<~, ~>>~ and ~min~ lines of the ~builtins~ table.
Left alone: docs/SPIKE-*.md and docs/handoffs/*, which are dated records of
what was true when they were written.
** What was run
- ~dune test --root . --force~ — exit 0, 0 FAIL lines, on the lane *and* in a
trial-merged tree. Through most of this lane it exited 1 instead, from
~test_dev.ml~'s ~trap_park~ rows racing and dying with
~Fatal error: exception Flan.Wire.Closed~ at ~dev-trap-null-alloc~ — measured
on an untouched worktree at dev-loop's tip with nothing of this lane in it,
and written up above under "Found while running it". Another lane has since
fixed it (~trap_park stops dying on the abort race~), so the green run is a
real green run rather than a lucky one.
One *other* ~test_dev.ml~ row failed twice across seven runs of identical
code — "the merged program never bound ...agent.sock", a daemon that did not
come up in time — and was green on every run either side, on the lane and in
the merged tree. The second failure named its own cause: the corpus sweep was
compiling in another worktree on the same machine, and the row gives the
daemon a fixed window to bind in. Run on an idle machine it is green.
Recorded rather than chased: it is a socket bind in the agent fixture, this
lane touches neither the agent nor the dyn side, and it looks like the same
family as the ~trap_park~ race that was just fixed, one row further along —
a timeout that is generous when nothing else is running and is not
otherwise.
- test/programs/widening.flan, new, with three acceptance rows — default, -O0
and ~--x86~ — and its output diffed by hand across the two backends before
the rows were written. Byte-identical.
- The lattice's edges pinned in test_flan.ml: what widens, what does not, the
two calls that could have gone the other way (int-into-float exact-only, and
equal-width cross-signedness), container invariance, the join in both
operand orders, the literal rule still standing, and the shift carve-out in
both directions.
- *Verified in a trial-merged tree, not only on the lane.* dev-loop moved
eight times while this was open, and the acceptance rows, the full suite and
the sweep were re-run against the last of them. The branch caught up by
rebase until the notes file made that expensive — every commit of this lane
touches FIX.org and so conflicted with every landing that also did — and
finishes with an ordinary merge of dev-loop into the lane instead, resolved
once. The merge back into dev-loop is clean, and was built, run and tested
as a merged tree rather than only on the branch.
- *The corpus sweep, base against lane.* Headless programs (test/programs/)
were compiled, ~check~ed and run, and the diff of the whole lot is a single
pure addition: widening.flan's own rows. Not one existing program's
diagnostics, output or exit status moved.
The thirteen test programs that import ~vendor:raylib~ were not run either,
for the same reason, and got the same treatment as examples/ below:
~check~'s diagnostics are identical on both sides, LLVM ~emit~ is
byte-identical, and the x86 difference is the prelude-line strings and
nothing else.
examples/ were *not run*. They link raylib and every one of them opens a
real window on the author's desktop, so the comparison there is ~check~'s
exit status and diagnostics plus a byte-diff of ~emit~ and ~emit --x86~.
LLVM output is byte-identical for all of them — after the same
prelude-line normalisation the x86 comparison needs, which the LLVM diff gets
for free because it spells those strings out as text where x86 emits them as
~.byte~ data. The x86 output differs in 28
of them and every differing byte is inside a ~<prelude>:line:col~ string —
this lane's comment rewrites moved prelude source lines by three, and the
x86 backend spells those strings out as ~.byte~ data. Normalising the
prelude line number makes both backends byte-identical everywhere.
- A global-initialiser check by hand, both backends: a widened ~defvar~
initialiser, a widened struct field in a struct literal, a widened array
element, and a widened ~set~. The concern was that a ~Cast~ in an
initialiser would stop being an LLVM constant; it does not, and the two
backends print the same six lines. A ~defconst~ of a float *from* an integer
constant is refused, with the existing "must be a compile-time constant"
sentence — the folder is integers-only and says so.
** What this lane did not do
- ~dyn~ is untouched in both directions.
- No ~Vec~, slice or array element type converts, and nothing was added that
could make one.
- The ~@x86~ and ~@sanitize~ sweeps were not run; per the sweep policy they
belong to the batch after several lanes land. The individual ~--x86~ builds
the policy does require were run, and are the acceptance row and the sweep
above.
** Review round two: what the first version got wrong
Three findings, all in the mechanism rather than in the lattice, and all from
the same root — the join is implemented as a *trial* (ask the second operand
for the first operand's type; reconsider only if that refuses), and a trial
that catches an exception is not free the way a trial that returns an option
is.
*1. An abandoned trial left its bindings behind.* ~scoped~ restores
~ctx.scope~ on the way out, and an exception does not take that way out — so
every binding the abandoned pass made survived into the enclosing scope. Two
symptoms, and the second is the serious one:
- a name that should be unknown resolved anyway, and
- the abandoned binding *shadowed* a live one. ~(let [t i32-x] (println (+
i32-x (let [t i64-y] t))) (println t))~ printed the sum and then ~0~ — the
outer ~t~ read through the dead inner binding's slot, which nothing ever
stored into. An uninitialised stack read, in a program the compiler
accepted, on both backends.
Fixed with ~trial~, which snapshots the context and puts it back when the
trial refuses. ~scoped~ itself is untouched — it is shared by every
scope-opening form in the file and this is not its problem to solve. ~trial~
also narrows the catch to ~Loc.Error~: a timeout or a stack overflow is not a
refusal to reconsider, and continuing past one would turn a resource failure
into a wrong answer.
*The first version of that fix restored six chosen fields, and the choice was
wrong.* Review round three found three more, and the worst of them inverts the
symptom: where a leaked binding produces a false *accept*, a leaked window
produces a false *refusal*.
- ~in_frames~. ~check_frames~ sets it, threads the expectation into the body's
last form, and clears it on the way out. A trial abandoned inside that
window leaves the flag stuck, so
: (println (+ i32-x (handler-bind [] i64-y)))
: (return 0)
— which compiled before this lane and compiles again now — was refused with
"return is not allowed inside handler-bind yet", pointing at a line with no
~handler-bind~ within sight of it. A valid program refused for a reason that
is not in the program.
- ~loops~, the same window via ~loop~: a leaked ~Lrecur~ made an invalid
~break~ answer "the nearest loop is a (loop ...), which answers with the
value of its body" instead of "break is only allowed inside a loop". No bad
accept, a thoroughly misleading refusal.
- ~defer_block~, message text only, and leaked with ~loops~.
*So the subset was replaced by the whole record.* ~trial~ now restores every
mutable field of ~ctx~ — the three above, the six from round two, and
~defer_ok~, ~tail~ and ~outer_what~, which would self-heal on their own and
are restored anyway, because "this one cannot currently leak" is precisely the
reasoning that produced two rounds of leaks. The destructuring is closed and
carries ~[@warning "+9"]~, so adding a field to ~ctx~ stops ~trial~ compiling
until somebody decides about it. *Verified that the guard guards*: removing
one field from the pattern by hand fails the build, naming the field.
One thing is deliberately not restored, and it is on ~env~ rather than ~ctx~:
an abandoned trial that lifted a function out of an ~fn~ literal leaves it in
~env.lifted~. That is dead and harmless — the names are ~fn/<owner>/N~ handed
out by count, so the live pass gets fresh ones and nothing refers to the
orphan — and it rides into the module as a function nobody calls. Left because
~env~ is the program's table rather than this form's, and rewinding it would
mean deciding what else on ~env~ a trial may have touched.
[Corrected by the milestone-5 lane, below: the generic instantiation cache
does not rewind itself either, and does not need to. ~instantiate~ rewinds a
copy whose *body* refused, which is a different event from a copy the caller
abandoned. The abandoned one is harmless because the trial and the live pass
cannot disagree about which copy to make.]
All five symptoms pinned — the two accepts, the shadow, the unknown name, and
the loop diagnostic.
*2. The trial reconsidered literals.* Written up under the join rule above.
The short version: ~(+ u8-thing 300)~ compiled, at i32, answering 555. The
decision was literals-unchanged and now the code says so, by kind rather than
by hope.
*3. Three globals collided with the prelude.* The dogfood batch added
~u8-max~, ~u16-max~ and ~u32-max~ as prelude ~defconst~s while this lane was
open, and the acceptance program had defined its own. The textual merge was
clean and all three acceptance rows died on "defined twice" in the merged
tree, which is precisely the failure a per-lane ~dune test~ cannot see. Every
global and function in test/programs/widening.flan now carries a ~w-~ prefix,
and the rows were re-run in a trial-merged tree rather than only on the lane.
** Collisions with the lanes that landed underneath
Three, each read by hand rather than trusted to the auto-merge:
- *The diagnostics lane* kinded ~expect~'s mismatch as
~check/type-mismatch~ so a call-argument site can recognise it. Its wording
and its mechanism win; ~numeric_note~ rides on the same message, because a
reader who has just been told i64 and i32 are different types needs telling
in the same breath which direction needed nothing.
- *The struct lane* added ~check_bare~ and ~positional_struct~. No overlap:
it calls ~expect~, this lane added an arm inside it. The intersection — a
struct literal whose field initialisers widen — was compiled and run on both
backends by hand.
- *The int/float alias lane* pinned ~(+ int-var i64-var)~ as a type error,
with a comment saying the pin was written as identity so it would survive
whatever the widening table grew into. It was not written that way — it
pinned a refusal and a message — and it is the one refusal pin in the suite
this lane makes legal. Rewritten to pin identity for real: the mixed form is
accepted at i64 under ~int~ exactly as under ~i32~, and the narrowing back
into ~int~ is still refused, naming ~i32~ because that is what ~int~ erases
to.
** Stale claims elsewhere, and one left alone
~runtime/flan_dyn.c~'s ~flan_dyn_need_f64~ note and
~runtime/flan_dyn_stub.c~'s arithmetic note both said the typed language has
no implicit widening at all. Rewritten, and the rewrite is not a hedge: the
typed language *does* widen an integer into a float now, but only the exact
ones, and the dyn box carries integers at i64 — the one width that reaches no
float on the lattice. So both boundaries refuse exactly what they refused, for
a reason that is now stated correctly.
~web/index.html~ (two places) makes the same stale claim. *Left alone
deliberately*: the website has its own rewrite lane, and a marketing page is
not the place for this lane to be making edits it cannot test. Flagged here so
that lane picks it up.
* (agent/start) lost its argument, 2026-09-20
Four notes from the lane that made the socket path optional and bound it
before main. Three of them are about ground this lane deliberately did not
take; the fourth is a line the author can delete at leisure.
** sand.flan can drop its socket path
=(agent/start "/tmp/flan-sand.sock")= at sand.flan:125 still works and always
will — the explicit form is not going anywhere. But the path was only ever a
value nothing read under =flan dev=, because the daemon overrides it through
FLAN_AGENT_SOCKET, and the zero-argument =(agent/start)= now does the right
thing in both places: the daemon's socket when there is one, and an announced
=/tmp/flan-agent-<pid>-<clock>.sock= when there is not. Changing that line is
a one-word edit whenever the author feels like it; this lane does not touch
sand.flan.
With the constructor below, sand.flan could delete the call outright — it
calls =(agent/poll)= in its frame loop, which is the condition. That is a
bigger claim than a shortened line and is worth making deliberately.
** Auto-start reaches as far as the linker does, and no further
=vendor/agent/flan_agent.c='s =auto_start= constructor binds FLAN_AGENT_SOCKET
before main, so a program under =flan dev= needs no =(agent/start)= at all.
What it cannot do is reach a program that never mentions the agent: =Reach=
prunes a package nothing calls into, so an executable with no =(import agent
...)= — or one that imports it and calls nothing — does not link the file the
constructor is in. There is nothing to run.
So the true scope is: *a program that calls =(agent/poll)= or =(agent/wait)=
and has dropped its start call*. That is the ceremony the feature was asked to
remove, and it is removed. Full invisibility — a dev build that links the
agent because it is a dev build, whether or not the source says so — needs the
package force-linked from Load/Build, which are files this lane did not own
and a decision about what =--dev= means rather than about the agent.
The constructor is also not =--dev=-only, because nothing in
=vendor/agent/flan_agent.c= can tell the two builds apart: the dev runtime is
linked either way and there is no weak symbol to ask. A *release* binary that
links the agent and is run with FLAN_AGENT_SOCKET set in its environment
therefore binds a listener it would not have bound before. Only =flan dev=
sets that variable and it never runs release builds, so this is a sentence
about the shape of the gate rather than an observed problem — but it is the
one behavioural difference outside the dev loop and it should be said.
The way it would be felt is theft rather than noise, and that is worth
spelling out: =start_on= unlinks the path before binding it, because a stale
socket from a previous run is the ordinary case. So if FLAN_AGENT_SOCKET ever
leaks into a shell's exported environment — a person exporting it by hand to
drive a program with =nc=, a terminal opened from a daemon's child — every
agent-linked program started from that shell takes the path away from whoever
bound it first. The earlier program keeps an fd on a socket with no name and
goes silently unreachable: the daemon that was talking to it now reaches the
newcomer. Before this lane the unlink was reached only by an explicit
=(agent/start ...)=, which is a line somebody wrote; now any agent-linked
program run in that environment does it before main. The gate is the same
variable either way, so the fix, if this is ever felt, is a narrower gate
rather than a narrower unlink.
** The daemon's "has not called (agent/start ...)" note is now unreachable
=install_note= (lib/dev.ml:789) and the =describe= branch at lib/dev.ml:1085
say, of a RUNNING program whose socket is not bound, that a redefinition
installs at its next =(agent/poll)= and not at all if there is none. For a
program that links the agent that cannot happen any more: the constructor
binds before main, so by the time any editor can ask, =agent_bound= is true.
It was true of exactly one thing, and the constructor is what removed it: a
merged session whose program *links* the agent, where the ring is reachable
in-process from the first instant while the socket is not bound until
=(agent/start ...)= runs. Bound before main, that window is gone. Going
through the other three shapes leaves nothing:
- *merged, program links the agent* — the socket is bound before main, so
=agent_bound= is true by the time any editor can ask. This is the window
above, closed.
- *merged, program does not link the agent* — there is no =flan_agent_request=
in the process and no socket either, so the delivery is refused with "cannot
reach the program on ..." and never reaches =install_note= at all. Pinned as
of this lane by =programs/dev-noagent-running.flan= and its row in
test_dev.ml, which also holds that the session survives the refusal.
- *two-process*=two_process= kills the child and =failwith=s when the
socket never appears, so a program with no agent has no session to be sent
anything.
So the branch at lib/dev.ml:789 and the =describe= arm at lib/dev.ml:1085 are
unreachable, not merely unexercised. Retiring them is the author's call over a
lane that merged days ago, not this one's — they are left in place, saying a
true thing about a state nothing can now be in.
Two rows nearby are about different sites and should not be mistaken for
cover: the =dev-noagent.flan= row asserts the *daemon's own stderr warning*,
said by the accept loop once the ten-second deadline is behind it, and the
late-agent row asserts the note's *absence*.
** The agent socket under the daemon is still the temp directory's problem
=start_on= now stashes the path it bound and unlinks it three ways: an atexit
for an ordinary exit, and by hand in =die_now= and =orphan_die=, which both
leave by =_exit= and skip the atexit chain deliberately. That covers a program
run on its own, a program aborted out of the break loop, and an orphan whose
daemon died.
It does not cover an ordinary =flan dev= session ending, and cannot: the
two-process daemon kills its child with SIGTERM and the merged session leaves
by =Unix._exit 0=, neither of which runs an atexit. The socket sits in
=/tmp/flan-dev-<pid>/= and goes when that directory goes — which is the item
above, "The daemon leaves its temp directory behind", still open. No separate
fix is wanted here; the session-end cleanup that item asks for takes the
socket with it.
* builtin/, the reserved qualifier, 2026-09-20
The author's decision, in the author's words:
#+begin_quote
"the full spelling is fine, I like that."
#+end_quote
This closes the dead end disclosed the same day by "Shadowing a builtin,
2026-09-20": a defn named after a builtin wins program-wide inside its own
file, and before this there was no remaining spelling for the thing it had
taken over, so a definition that meant to *wrap* a builtin was unbounded
recursion with no diagnostic. ~builtin/len~ is the builtin ~len~, whatever
else the file has decided ~len~ means, and it is legal whether or not
anything is shadowed — a spelling that only compiled while some other
declaration existed would be one nobody could write down in advance.
** The resolver
Two interceptions, both at the very top of the dispatch they sit in, and each
strips the prefix and re-enters the same function with one flag set:
: | _ when not qualified && qualified_builtin name <> None ->
: let bare = Option.get (qualified_builtin name) in
: if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
: named_call ~qualified:true ctx ~want loc bare args
~named_call~ gains ~?(qualified = false)~ and the shadowing guard beneath it
becomes ~not qualified && shadows_builtin ...~. That flag is the whole of the
mechanism: a qualified call has already said which of the two readings it
means, so there is nothing left for shadowing to decide, and every arm below
sees the *bare* name — which is why ~(builtin/len 1 2)~ is refused with
exactly the sentence ~(len 1 2)~ would get. It cannot loop:
~builtin/builtin/len~ strips once and is then refused by name, because
~builtin/len~ is not in ~builtin_set~.
~var~ gains the same arm, for the builtins that are names rather than calls —
~true~, ~false~, ~nil~, ~None~, ~context/allocator~, ~context/temp~. The last
two fall out for free: stripping one prefix off ~builtin/context/allocator~
leaves a name the match already has an arm for.
*The one asymmetry, and it is load-bearing.* ~var~'s qualified path must
refuse where the call path falls through. A qualified name that gets past the
value arms is in ~builtin_set~ but is call-only, and letting it fall into
~lookup~/~globals~/~fns~ would answer ~builtin/len~ with the address of the
very definition the qualifier was written to escape — the feature inverted,
silently. So a guarded ~| _ when qualified ->~ arm sits above the catch-all:
: builtin/len is the builtin len, which is a call and not a value — a builtin
: has no address to pass. Write (builtin/len ...) at the call, or wrap it in a
: defn to pass that
** Why ~/~ and not ~(builtin get)~
The spelling is the package qualifier's, deliberately. A reader who knows
that ~rl/draw-fps~ is ~draw-fps~ from the package imported as ~rl~ already
knows what ~builtin/len~ is and needs no second syntax. What makes it
unambiguous is that ~builtin~ is *reserved* rather than resolved: every
qualifier in a finished program comes from ~Load.qualify~, and every
~qualify~ takes its alias from an ~import~ form, so refusing that one alias
is the whole of the reservation — there is no other door.
The refusal is at the top of ~Load.import~, before the package is read, which
also covers a package importing one under that alias since every import goes
through that function:
: builtin is a reserved qualifier and cannot be an import alias: builtin/name
: always means the compiler's builtin, which is how a program reaches a
: builtin it has shadowed. Import this package under another alias
*It is the alias and not the directory.* The task that asked for this said "a
package directory named ~builtin~ must be refused at import", and the code
says something slightly narrower, because the alias is always written out —
~(import rl "vendor:raylib")~, ~parse.ml~'s two-element form — and a
directory never becomes a qualifier on its own. A package whose directory is
called ~builtin~ imports fine under any other name and collides with nothing;
~(import builtin "anything")~ is what is refused. The directory is not named
in the message either: it has been resolved to an absolute path by then, and
the caret is already under the import form, which carries the path the reader
wrote.
*And from the other side.* ~(defn builtin/len ...)~ reads — ~/~ is an
ordinary symbol character — and would land in ~env.fns~ under a name nothing
could ever call, since the prefix is stripped before any table is consulted.
~Check.collect~ refuses any declaration whose name carries the prefix, over
~Ast.declared_name~ so it covers every declaration kind at once:
: builtin/len cannot be declared: builtin/ is a reserved qualifier, so a name
: spelled with it reaches the compiler's builtins and never a declaration —
: nothing could call this one
** The reader, and ~builtin/+~
No exception was needed. ~reader.ml~'s ~is_delimiter~ makes ~/~ ordinary and
says so in its own comment (~rl/draw-fps~ is one symbol), and the operator
characters are ordinary for the same reason ~+~ is a symbol at all. The one
path that could have taken ~builtin/+~ apart is the number reader, which
takes a token starting with a digit or with ~-~/~+~ followed by a digit, and
~builtin/+~ starts with ~b~. So it arrives as one symbol and there is nothing
to document as unreadable. ~(builtin/+ 1 2)~ is 3 in a file whose ~+~ answers
99, and it lowers to the ~Add~ prim rather than to a ~Call~.
** The refusals for a qualifier that reaches nothing
Its own kind, ~check/unknown-builtin~, and the did-you-mean is over
~builtin_names~ *alone* — not over the program's own names. The reader wrote
the qualifier, so they were reaching for a compiler name, and offering them a
defn called ~lem~ would answer a question they did not ask. Every other
did-you-mean in ~check.ml~ keeps the candidates it already had, and the
dot-access diagnostic is untouched: nothing anywhere suggests a ~builtin/~
spelling for a name that was written bare.
: nosuch is not a builtin, so builtin/nosuch reaches nothing. The builtin/
: qualifier reaches the compiler's own names and nothing else; an ordinary
: function is called by the name it was defined under
: lne is not a builtin, so builtin/lne reaches nothing — did you mean
: builtin/len?
: builtin/ needs a name after it — the qualifier reaches a builtin, as
: (builtin/len v)
** The warning now names the escape
: shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition — the builtin stays reachable as builtin/get
One sentence still, and the second half is the half the reader wants next:
they are being told the name was taken over, and what is left is the thing
they are about to go looking for.
** The arm-scraper, which nearly broke twice
~test_flan.ml~ reads ~check.ml~'s source to cross-check the builtin arms
against ~Check.builtins~, keying the two regions on the literal prefixes
~"and named_call "~ and ~"and var ctx "~. So the optional argument had to go
*after* ~ctx~ in ~var~ — ~and var ctx ?(qualified = false) loc ~want name~ —
and a leading one would have emptied that region and reported ~true~,
~false~, ~nil~, ~None~ and the two ~context/~ names as deleted arms. Both new
arms are guarded and carry no string literal in the head, so the scraper skips
them exactly as it skips the shadowing guard; nothing in that test changed.
** Pins
- ~test_flan.ml~: ~builtin/len~ checks with nothing shadowed; with a
two-parameter ~len~ in the way, the bare call at two arguments checks and
the qualified one is refused at *the builtin's* arity ("len takes 1
argument, given 2") — the pair is refusable only under one reading each, so
it cannot pass under both.
- ~test_flan.ml~: the self-referential wrapper ~(defn len [s string] i32
(builtin/+ 1 (builtin/len s)))~ checks and its body holds no ~Call ("len",
_)~ anywhere, walked with ~Tast.walk~ — the difference between a wrapper
and a loop, asserted rather than assumed.
- ~test_flan.ml~: ~builtin/+~ with ~+~ shadowed lowers to ~Prim (Add, _)~;
~builtin/nil~ and ~builtin/context/allocator~ check; ~builtin/len~ in a
value position is refused with the call-and-not-a-value sentence.
- ~test_flan.ml~: ~check/unknown-builtin~ as a kind, both of its messages
whole (with and without the near miss), and the declaration refusal; plus
that a *bare* typo is still answered bare ("did you mean len?") and never
with a qualifier.
- ~test_flan.ml~, changed: the two shadow-warning messages, matched whole,
now carry the escape clause.
- ~test_acceptance.ml~: ~programs/builtin-qualified.flan~ outputs
~9\n5\n4\n99\n3\n~ — builtin/max unshadowed, the wrapper's 5, builtin/len's
4 beside it, the shadowed operator's 99, builtin/+'s 3. It is an ordinary
corpus row, so the ~@x86~ sweep compares both backends over it.
- ~test_acceptance.ml~: ~programs/import-builtin-alias.flan~ is refused, by
the alias sentence and by the clause saying what the qualifier is for.
** What was run
~dune test~ in the lane's worktree, green. Per the batching policy the ~@x86~
and ~@sanitize~ sweeps were not run here — the new corpus row is registered
for the x86 survey through the existing ~programs/*.flan~ glob and will be
compared on the next sweep.
* Milestone 5, and the sweep behind it, 2026-09-20
** What was already there
Almost all of it, and the first finding of this lane is that finding.
docs/SPIKE-GENERICS.md carries a banner saying so — "it stopped being current
when generics landed for real, on 2026-09-13" — and the code agrees:
~$t~ binds and bare ~t~ reads; ~collect~ puts a generic signature in ~gsigs~
and keeps it out of ~env.fns~; ~generic_call~ binds left to right,
substituting each binding into the parameters still to come; ~instantiate~
caches by ~Types.equal~ on the concrete parameter list; the body is checked
once abstractly so a refusal lands at the definition; ~{:where~ carries four
predicates with an entailment table; ~runaway~ caps the depth; a copy is an
ordinary ~Tast.fn~ with a cell, so both backends were untouched then and are
untouched now; and ~Check.instantiations~ expands a redefined generic's name
for ~Session.eval~, which test_session pins at four shapes including a copy
the running process was never built with.
So this lane is not "start M5". It is the four things M5 did not reach, and
the sweep the author asked for.
** 1. A written zero may stand where a numeric type variable stands
The one thing the landed generics could not express was the family the whole
feature was asked for:
#+begin_src lisp
(defn pos? [x $t] bool {:where (numeric? $t)} (> x 0))
#+end_src
~(> x 0)~ was refused with "expected t, found the integer literal 0", because
~int_literal~ had no arm for a want that is a type variable. It has one now,
and *the bound is what makes it sound rather than optimistic*: every type
~numeric?~ admits is an integer or a float, and an untyped integer constant is
usable at all of them, so there is no instantiation of a ~numeric?~ variable
at which the literal has no meaning. Under anything weaker there is —
~ordered?~ admits an enum, which holds no number — so ~numeric?~ is what is
asked for and the refusal names it.
*The float literal is refused at a type variable even under ~numeric?~*, and
that asymmetry is the concrete arms' own rather than a new rule: an integer
constant is usable where a float is wanted, and a float literal is never
usable where an integer is wanted. ~numeric?~ covers both halves of the
numbers, so a body written with ~0.5~ has no meaning at the integer half of
its own bound, and refusing at the definition is what the abstract pass is
for.
Nothing built here is emitted. The abstract pass builds a placeholder at i64
and throws it away with the rest of the body; each copy re-checks the same
form with the variable substituted, and that is where the literal is built at
the concrete width and range-checked — so ~(+ x 300)~ is fine at i32 and a
refusal at u8, and u8 is where it is refused.
** 2. Generics and implicit widening
*Decided: implicit widening does not cross a generic binding.*
Widening landed days after generics did, and the rule the two of them left
between them read off the order the arguments were written in:
#+begin_src lisp
(defn eq2? [a $t b $t] bool {:where (equal? $t)} (= a b))
(eq2? (i8 3) (i64 3)) ; refused — i64 into i8 can lose
(eq2? (i64 3) (i8 3)) ; accepted — $t was i64 already, the i8 widened in
#+end_src
Same two values, same function, one copy at i8 refused and one copy at i64
generated. Neither answer is unsound — a widen cannot change a number — so
this is not a bug report; it is a decision nobody had taken, because the two
features had never been in the tree at the same time.
Taken: a concrete argument at a variable an earlier argument already bound has
to be that type. Both orders refuse now, with one sentence naming the binding,
the argument and the cast to write.
*Why refuse rather than join.* Letting the pair meet at the wider type is the
other coherent rule, and it is the better one if the ergonomics ask for it.
It can be added later without invalidating a single program written under this
rule. The reverse is not true. Refusing is the direction that can be walked
back, and with two features that had never met, that is the direction to be
wrong in.
The rule costs almost nothing, because ~Types.widens_to~ admits only numeric
scalars: a variable bound inside ~[$t]~ or ~(Fn [$t $t] bool)~ leaves a
parameter no widening ever applied to, so ~sort-by~ and the whole fn-literal
path are untouched by construction. Two exceptions keep the ergonomics —
an untyped literal has no type of its own to keep, so it still takes the
variable's; and a form with no type without a want (~(zeroed)~) is asked for
its natural type through a ~trial~ and falls back to the want when that
refuses.
*** And the composition with the trial machinery, which is the reason to care
A binary operator whose operands disagree re-checks the right one at the left
one's type inside a ~trial~, so a generic call written there is checked twice,
once in a pass that is thrown away. An instantiation made during the discarded
pass does *not* go back out: ~instantiate~ rewinds a copy whose body refused,
which is a different event.
It does not have to, and the reason is this lane's own rule rather than luck.
*A generic call's instantiation is read off its arguments and never off the
ambient want* — an unbound variable is checked with no expectation at all, and
a bound one no longer widens — so the trial and the live pass ask
~instantiate~ for the same types, the second ask is a cache hit on the first,
and exactly one copy exists either way. Pinned by counting copies in the
checked program, not by reading the comment.
The widening lane's own note said the instantiation cache "already rewinds
itself"; it does not, and the entry above has been corrected in place.
** 3. A type variable is not instantiated at dyn
*Decided: refused, at the binding.*
Nothing stopped it before, because ~dyn~ is an ordinary case of ~Types.t~ and
substituted like any other type. The copy was then made and walked into the
dyn answers that are not all there, and the refusal arrived from inside the
generic's own source: ~(or-else (Some d) e)~ over two dyns was reported
against ~<prelude>:385~, about a descriptor the collector cannot build for
~(Option dyn)~ — a line the caller did not write and cannot act on. Every
such case is this refusal arriving late and in the wrong place.
The message does not only say no. Two models answer "one body, many types"
here and they are not rivals: this one copies per written type at compile
time, ~defgeneric~/~defmethod~ dispatch at run time on a value that carries
its own. A dyn argument is asking the second question of the first machinery,
so the sentence names the other spelling.
Only the unbounded half is new — a variable carrying a ~{:where}~ clause was
already refused by ~pred_holds~, and that refusal is left in front of this one
deliberately, because it names the predicate the signature wrote down.
*Open, and the author's:* whether dyn should eventually flow through a
generic at all. Refusing now is the walk-backable direction for the same
reason as the widening decision.
** 4. Three messages about milestone 5, from a milestone that arrived
Swept, and they were not all the same kind of stale.
- ~check.ml~'s unknown-lowercase-type arm reported "generic code over the
type variable X is not implemented yet — milestone 5 work". Generics are
implemented, and ~resolve_name~ consults ~env.tyvars~ and ~env.subst~ long
before anything reaches that arm, so a lowercase name arriving there is a
typo too far from any type to guess at, or a type variable nobody
introduced. It names the sigil that would introduce it.
- The type resolver's "X takes no type arguments — generics are milestone 5"
and the value-position fork's "a type given type arguments is generic code,
which is milestone 5" are about the *other* half, which is genuinely
unbuilt: ~Types.Named~ is a bare string with no room for parameters, and
giving it some is a change to ~Types.t~ and therefore to the layout
calculator, both backends, ~Render~ and DWARF. Both now say a generic
*type* is not there yet and point at the generic function that is. Nothing
was built for them.
Three test needles moved with them.
** 5. The prelude sweep — what collapsed, what did not
*Added:* ~pos?~, ~neg?~, ~zero?~. Three questions about a number's sign, one
body each, answering at i8 through u64 and at both float widths. They were
never written before because without a type variable they are three functions
per width; they are writable now because of item 1 above and not because of
the type variable alone.
*Declined, with the real reason written where the old one was:*
- ~abs-i32~/~abs-i64~ stay two functions. The comment's old reason — "there
are no generics over the numeric types" — is false now, and the generic
body checks and runs at every integer width. What stops it is the float
half of its own bound: ~numeric?~ is the only predicate that admits a
written ~0~ and it admits f32/f64 too, and ~(if (< x 0) (- 0 x) x)~ is the
wrong abs for a float — it hands back a negative zero. The float pair is
libm's ~fabs~ for exactly that reason. *The collapse waits on a bound that
spells "an integer type".*
- ~min~/~max~ stay builtins. Not a type-system limit: they are variadic, and
each step slots both of its sides so every operand is evaluated exactly
once. A binary prelude generic would have to be nested at the call site,
which puts the double evaluation back. Their generic half was never missing
~ordered?~ already admits them in any body that declares it.
*** An ~integer?~ predicate — recorded, not built
It would collapse ~abs~, and it would let ~%~, the bitwise operators and the
shifts be written over a variable. It is four lines in ~pred_holds~,
~predicate_names~ and ~pred_entails~ (declared ~integer?~ gives ~numeric?~,
~ordered?~ and ~equal?~). It is not built here because adding a predicate is
language surface — the vocabulary a programmer writes — and that is the
author's call, not a lane's.
** What this lane did not build, deliberately
- *Generic types.* ~(defstruct Pair [a $t b $t])~ cannot be spelled, and
the price is in the spike: ~Types.t~ and every backend. Out of scope, and
the two messages above now say so accurately.
- *"In instantiation of" notes.* A refusal inside a copy points at the
generic's source with no note saying which call site asked for that type.
~Check.instantiation_origin~ exists and ~session.ml~ already uses it for
compatibility reports, so the data is there; wiring it into every ~fail~
under an instantiation is the spike's "bulky, not hard" bucket and is a
lane of its own. Two of the three places it mattered most are closed by
items 2 and 3 above, which move those refusals to the call site outright.
- *~$n~ in length position.* Same price as generic types, smaller prize.
** Pins added
Cross-package generics (~programs/pkg-generic.flan~ and a new
~pkgs/gen~ package — one generic at two element types, one calling another in
its own package at its own variable so the transitive copy is generated from a
call site two files away, and a local generic calling across the boundary at
its own ~$t~), both backends and -O0; the package bound refused at the call
with the clause quoted; the literal family at six numeric types in the
generics corpus row; the prelude's three under their real names including
~zero?~ at ~-0.0~; both widening orders refusing; the written conversion and
the untyped literal still accepted; the fn-literal path unaffected; ~(zeroed)~
still getting its want; ~$t~ at dyn and at ~(Option dyn)~; a bounded variable
still refused by its bound; the abandoned-trial copy count; and the three
reworded messages.
Dev-loop reload needed nothing: test_session already pins ~C-c C-c~ on a
generic installing its copies, the callee side, the absence of a stale cache
across two evaluations, and a redefinition that needs a copy the process was
never built with.
** One stale claim flagged, not touched
plan.org's Types section still lists *five* predicates and describes
~copyable?~ and "a type variable is move-only by default" at length.
spec-memory.md's Generics section already records that ~copyable?~ went with
the second repeal, and ~predicate_names~ in check.ml has four. plan.org is the
one that is behind. Left alone deliberately: it is the ownership-repeal lane's
sentence to retire, not this one's, and it is flagged here so that lane picks
it up.
spec-memory.md's Generics section gained the three rules this lane decided —
the literal under ~numeric?~, the widening boundary, and dyn — because the
spike banner names that section and plan.org's Types as the current account,
and all three are observable from a program.
* The Emacs buffer story, consolidated, 2026-09-20
The decision: two streams, one tool list, and the rest untouched.
- Two streams. ~*flan*~ (renamed from ~*flan-dev*~) is the daemon's log —
compilation-minor-mode, jump-to-error, and now a mirror of the program's
println output, so output lands somewhere before any REPL interaction has
happened. ~*flan-repl*~ is the working stream: eval results, the program's
output inserted above the prompt (output first, then the value), and a
one-line summary when a compile fails — "1 error — see ~*flan-diagnostics*~".
- One tool list. ~*flan-diagnostics*~ holds everything the compiler reports:
the errors as today, popped up (shown, not selected) when one lands, and
below them the memory-allocation sites ~flan-check-memory~ asks the
~(:op "memory")~ op for — one section, replaced whole on every ask, each
line in its kind's faint face (memory/gc, memory/native). The buffer got a
major mode: read-only, ~n~/~p~/~RET~ throughout both sections.
- ~*flan-output*~ is removed entirely, its ~C-c C-o~ with it. The key now
clears the REPL's last send; ~C-c M-o~ clears the transcript whole
(CIDER's pair), both bound in flan-mode and flan-repl-mode.
- Inspector and break/conditions buffers unchanged.
No daemon changes: the program's output already rides every reply's
~:output~, so both destinations are editor-side routing in
~flan--append-output~. A REPL rejection is distinguished from a connection
failure (~:client~ on the synthetic reply); only the compiler's messages
reach the diagnostics list.
** What was run
~dune test --root .~ green, test_emacs and test_cider included; the three
changed .el files byte-compile clean with warnings as errors. New checks in
test-flan.el: output reaches the daemon buffer with no REPL open, output
lands above the value at the REPL and is mirrored, the rejection summary and
its full message in the diagnostics list, both clears, and the two-section
layout (errors above, memory below, replace-whole, clear takes both).
* (array-fill [n ...] v) and (array-gen [n ...] f), 2026-09-20
DISCUSS.org asked for a value-producing array constructor: =(array n T)= is
the zeroed array and =dotimes= is Unit, so "an array of these" had no spelling
that could stand where an expression must — a defvar's initialiser being the
line the note was written about. These two are that expression, at any rank.
The dimensions sit in brackets and are the same compile-time lengths the
=[n T]= type spelling takes — an integer literal or a defconst's name, one
rule in one place (=array_len=) — with one extra condition the type spelling
does not need: a dimension has to fit an i32, because every index in the
language is an i32 and so is the loop that writes the elements.
The generator is a function value called once per element with one i32 index
per dimension, first dimension's index first, and its return type is the
element type. Row-major order is pinned as a promise, and the fill value and
the generator *value* are each evaluated once, before any loop runs — =(array-fill
[n] (next-id))= is one call and n copies of its answer.
The lowering is want-driven and reaches no backend: bind a slot, =Zero= it,
one =While= per dimension writing each element through =Set= of a =Pindex=,
answer the slot. Those are nodes both backends already had, so LLVM, x86 and
the js one all get the form with no edit. The annotation's element type is
threaded down as the want, so a fill value that disagrees with =[rows [cols
u8]]= is reported at the value in expected/found words, not as a whole-array
mismatch.
Composition is the ordinary kind: =(array-fill [2] (array-fill [3] 7))= is an
array whose fill value is an array, and it works because the inner form is
just an expression in the value slot. What does *not* exist is a nested
bracket syntax — =[2 [3]]= as a dimension list means nothing; ranks are
spelled flat, =(array-fill [2 3] 7)=.
** The inline fn, and the want it was owed
=(array-gen [3 4] (fn [i j] ...))= — the canonical form — was refused at
first: an fn takes its types from its position, this position carried no
=(Fn ...)= want, and =check_fn= answered "nothing here says what this fn's
parameters are". But the form *does* say: one i32 per dimension is the rank's
own promise. =check_array_gen= now hands an inline fn its parameter types
directly, with the annotated element type as the return want where the
annotation reaches that deep, and with the return left to the body where it
does not — so a bare =(array-gen [3] (fn [i] (* i i)))= infers =[3 i32]= the
same way a fill value infers its element. A body that disagrees with an
annotated element type is reported at the generator's answer — expected u8,
found f64, caret on the offending expression — per element, not per array.
Named defn generators check exactly as before, arity and index types in
array-gen's own words.
* integer?, the collapsed abs, and the join, 2026-09-20
The author's brief, verbatim in spirit: we want generic arithmetic as much as
possible; we are failing if a function that can be generalized needs variants
for different numerical types.
** integer?, the fifth predicate
~numeric?~ was one type too wide for a family of bodies. It is the only bound
that admits a written 0, and it admits f32 and f64 too — so an integer body
under it was instantiated at the floats, where ~(if (< x 0) (- 0 x) x)~ is
the wrong abs (a -0.0 comes back negative) and the bitwise operators, the
shifts and an integer-only ~%~ mean nothing at all. ~integer?~ admits every
integer kind, signed and unsigned, at every width, and refuses floats and
everything else: ~Types.is_integer~, wired into ~predicate_names~,
~pred_holds~ and the entailment table.
The entailments run one way. ~integer?~ entails ~numeric?~ — every integer is
a number, so the arithmetic, the written 0 and the untyped integer literal
all come with the one clause, through the same ~int_literal~ arm ~numeric?~
uses — and through it ~ordered?~ and ~equal?~. The reverse does not exist,
because it would let floats into ~bit-and~.
What it unlocked in the checker: the bitwise fold asks ~unconstrained~ for
~integer?~ now instead of ~numeric?~ (so ~(bit-and x 1)~ in a ~numeric?~ body
is refused at the *definition*, not from inside the generic's source at
whichever call site first instantiated at a float), and the shifts admit an
~integer?~-bounded variable where they refused every variable before. The
float literal in an ~integer?~-bounded body gets the bound's own sentence:
there is no instantiation at which it means anything. ~%~ stays ~numeric?~
deliberately — a typed float ~(% x y)~ is fmod and always was
(test/programs/math3.flan pins the four sign cases), and tightening it would
be a semantics change this predicate does not ask for.
** abs, collapsed
~abs-i32~ and ~abs-i64~ existed per width only because ~numeric?~ admitted
floats. They are one ~(defn abs [x $t] $t {:where (integer? $t)} ...)~ now,
answering at all six-and-more integer widths; the copies at i32 and i64 even
keep the old symbols, since an instantiation mangles to ~abs-i32~ and
~abs-i64~.
The decision between "integer? plus the float overloads" and "one numeric?
generic with a float-safe body": there is no float-safe body to write. ~(max
x (- 0 x))~ picks whichever zero sits in the wrong slot because -0.0 and 0.0
compare equal, and the branch spelling hands -0.0 back unchanged. The right
float abs is a sign-bit clear, which is libm's fabs and is already declared —
~abs-f32~/~abs-f64~ stay as the float spellings, and ~(abs 1.5)~ is refused
naming the bound. For that refusal to be the one a float caller sees,
~instantiate~ now checks the ~where~ clause *before* the name-collision
check; before the reorder, ~(abs 1.5)~ computed the sym ~abs-f64~ and died on
"already defined — rename one of them", which is the wrong sentence with no
fix in it.
Behaviour pinned identical: both signed minimums answer themselves (the
negation wraps, as every two's-complement abs), unsigned is the identity,
~(abs-f64 -0.0)~ is 0. test/programs/int-generic.flan, plus the math3 rows.
** The survey — what else numeric?-admits-floats was keeping per-width
The prelude's remaining per-width families, each left with its reason:
- ~sum-i32~/~sum-f32~ — the accumulator is a *different, wider* type than the
element ("the type $t accumulates into" is a type-level function no
predicate spells); their own comment already says so.
- ~append-i64~/~append-f64~ — two different runtime primitives.
- ~parse-i64~/~parse-f64~ — the variable would appear only in the return
type, which no argument determines and no syntax names.
- ~rand-i32-range~/~rand-f32-range~ — two different algorithms (Lemire
rejection vs. scale), not one body twice.
- ~sign-f32~ — its integer twin would write -1, which has no meaning at the
unsigned half of ~integer?~; a bound spelling "signed" does not exist and
is not asked for.
- ~min~/~max~ — builtins by decision (variadic, evaluate-once), untouched.
- The libm pairs — declares, one C symbol each; nothing to collapse.
So the survey's whole yield is abs, plus the *checker* generalizations above
that let user code write generic bit/shift/mod helpers it could not write at
all before (int-generic.flan's ~low-bits~, ~even?~, ~toggle~, ~halve~).
** The join, superseding "widening does not cross a generic binding"
The 2026-09-20 milestone-5 entry above took refusal as the walk-backable
direction and recorded the join as the coherent alternative. The author
walked it back the same day: *just pick the wider type for both.* The old
entry stands as written; this one supersedes it.
The rule as landed: numeric scalars bound to one ~$t~ resolve it to
whichever written type every one of them widens into — ~Types.join~, so
value-preserving widening only, never an invented third type... except that
an upper bound *in the set* found through a later argument is exactly that:
~(tri u32 i32 i64)~ has no join at the second argument and a perfectly good
one at the third, so a joinless pair is deferred and re-asked against the
final binding rather than refused on the spot. That is what makes acceptance
order-independent, which is pinned two ways: both orders accept, and both
orders of the whole program instantiate exactly one copy, at the wider type
(the pin counts ~eq2?-i64~ in the checked program's functions).
Still refused, each in its own words: a pair with no join anywhere (u64
against i64 — no type holds every value of both), and a variable the
signature also reaches through a container or function type (~index-of~'s
slice binds its element exactly; elements cannot be rewritten wider). The
arguments the final binding out-widened catch up through the same ~Cast~
node the written conversion builds, so the emitted copy never sees the
narrow type. Literals still decide as before — a bare literal at a bound
~$t~ takes the binding — and spec-memory.md's Generics section now carries
the joined rule.
** Still refused, known, deferred
A *compound constant expression* at a bounded ~$t~~(+ x (+ 1 2))~ where
~(+ x 3)~ works — is still refused: the literal arm admits a bare constant
at a type variable, and nothing folds the compound to a bare one before the
ask. Walk-backable (admitting more programs later invalidates nothing
written now), so it waits until a body actually wants it.
* Enum keyword prefixes, 2026-09-20
** The decision, author's words
raylib's enum keywords carry a disambiguating prefix, because bare members
collide across enums and with user code. Key members are ~:key-r~,
~:key-space~, ~:key-left-shift~; MouseButton members are ~:mouse-left~,
~:mouse-right~ and so on. ~mouse-~ over ~button-~ because gamepads have
buttons too. Only Key and MouseButton are decided; the rest of the survey is
below, awaiting a ruling per enum.
** The bindings directive extension
The `enum` line in vendor/raylib/bindings grew an optional third column: the
prefix the members carry on the Flan side, stripped before the C prefix is
applied. `enum Key KEY_ key-` checks ~key-r~ against KEY_R rather than
KEY_KEY_R; `enum MouseButton MOUSE_BUTTON_ mouse-` reaches MOUSE_BUTTON_LEFT
from ~mouse-left~. A member that does not carry the declared prefix is
reported, not checked under a guessed name — ~null~ beside a declared ~key-~
would otherwise build KEY_NULL, which the header happens to have, and the
naming rule would erode silently. A name the rule builds that the header
lacks is still reported, never skipped. `flan generate-c vendor/raylib` runs
green against raylib-5.5.h with both lines in place.
The checker also grew a did-you-mean for enum members: one edit away, and the
bare name of a prefixed member — ~:r~ suggests ~:key-r~, ~:left~ suggests
~:mouse-left~ at a MouseButton site.
** Open, author's call — the survey of the other nine enums
None are renamed; these are the collision-prone bare members found:
- TraceLogLevel: nearly all generic — ~all~, ~trace~, ~debug~, ~info~,
~warning~, ~error~, ~fatal~, ~none~. ~none~ also collides with Gesture's.
- Gesture: ~none~ (collides with TraceLogLevel's), ~tap~, ~hold~, ~drag~.
- CameraMode: ~custom~, ~free~ (also the name of the language's free).
- MouseCursor: ~default~, ~arrow~, ~crosshair~.
- TextureFilter: ~point~.
- GamepadButton: ~unknown~, ~middle~ (plus ~middle-left~/~middle-right~).
- GamepadAxis: ~left-x~/~left-y~/~right-x~/~right-y~ read gamepad-ish
already, but ~left-trigger~/~right-trigger~ sit one hyphen from
GamepadButton's ~left-trigger-1~/~2~ — a prefix ruling should take the two
enums together.
- CameraProjection (~perspective~, ~orthographic~) and PixelFormat
(~uncompressed-*~, ~compressed-*~) are effectively self-naming; low risk.
** Open, author's call — sand.flan
sand.flan calls ~(rl/key-pressed? :r)~ and ~(rl/mouse-button-down? :left)~
(lines 161166), and the default suite compiles it (test_session, and
test/programs/sand-headless.flan imports it). The file is the author's live
WIP and was not touched, so those two tests are red on this branch until the
three keywords there become ~:key-r~ / ~:mouse-left~.
* println is variadic, 2026-09-20
Author, dogfooding: "println should be variadic" — hit "println takes 1
argument, given 2".
Semantics chosen: Clojure's. Every argument prints in order, a single space
between each pair, println ends the line. (println) is the newline alone,
(print) is nothing. Single-argument call sites are byte-identical to before —
the space is a separator, never a trailer.
Mechanics: no prelude macro. print/println were never functions — they are
the checker's structural walk (lib/check.ml, the "print" | "println" arm;
the walk in lib/render.ml) — so the arm itself went variadic: each argument
is checked and rendered exactly as it was alone, with a one-byte " " write
interleaved. Typed and dyn arguments mix in one call because each gets its
own printer and both sinks share stdio's buffer (flan_write_stdout and
flan_dyn_print both go through stdout). One generic argument still defers
the whole call to instantiation. Diagnostics stay on the argument: each is
checked carrying its own loc and render.ml fails on the expression's loc,
so an unprintable second argument underlines that argument, not the form —
pinned in test_flan.ml. Output pinned by test/programs/println-variadic.flan
and its acceptance row (LLVM), spacing exact, "|" markers so a leaked
trailing space is a visible red.
* Break-loop display pass, 2026-09-20
Off a dogfooding session that hit BoundsError: "can I get a better error
message? I don't see a precise line number anywhere, what is s1 and s3? the
condition field messages are weird, the continue message is weird too, do a
full pass and reword things."
** Built
- Condition values render. The break loop stashes the condition pointer in
the agent's snapshot (it used to discard it); [flan_agent_condition] hands
it back on the stopped thread; a new daemon op =condition= builds a render
thunk over the struct's fields — render_locals pointed at the condition —
and delivers it at-stop, so resume-and-restop cannot read the old type over
the new pointer. The buffer's headline now reads the fields inline:
"BoundsError — low 648, high 648, length 100", nothing hardcoding any one
condition. Works for user =error= conditions and for the trap-built ones,
on both backends.
- Precise location. The bounds/slice/arith trap sites publish their loc
around the break-hook call ([flan_break_site] in flan_rt.c), the snapshot
copies it, agent verb =site= serves it, and =break= answers =:site= plus
the line's text as =:source=. The buffer draws "at file:line:col" under the
headline with the source line and a caret at the column.
- Compiler temps are hidden from the locals listing rather than refused as
=s4=; a shadowing rebind strips its =~N= except when the outer binding is
on the same list, where both keep their raw spelling ([Session.shown_names]).
- Rewording. Every bracketed implementation note is gone from the buffer
(they were implemented anyway); the refusal table is one short sentence per
section; the abort line says what abort does ("end the program here; the
dev session ends with it" — true: abort is _exit(134) and merged flan dev
is that process). A shadowed restart is now *takeable*: the buffer sends
=restart-at= with the index for every choice, name as receipt, so the
shadowed line just says "same name as N; taken by its number".
- A u8 shows its character where a person is inspecting: =97 (\a)= in a
frame's locals, in inspect, and in a condition's fields. Ruled by the
author: =[u8]= already renders as text, so a lone byte reading =97= was an
asymmetry exactly where someone is reading rather than computing.
=println= is untouched — a u8 is a number and that path is the program
talking. The switch is =Render.pointers=, which already marks the
inspecting side and which =println= passes as =None=, so the printing path
cannot acquire this by accident. Spellings answer to lib/reader.ml's
=read_byte= (the five named ones, and any single non-delimiter character),
so what is shown could be typed back; a byte with no spelling shows the
number alone rather than an invented escape or a raw control byte. The
table is in flan_dev.c as one call: the value is only known at run time,
and a chain over ninety-odd comparisons per rendered byte would have been
the walk paying for its own shape. Pinned on both backends with a
printable, a named and an unprintable byte, and =println (u8 97)= pinned
bare in the acceptance table — the existing 255 could not tell the two
apart.
** Deferred, ready to build
- Restart locations. The =%restart= frame is mirrored across emit.ml, x86.ml
and flan_rt.c (fields 0-9 today), so giving =continue= a file:line:col
means: two fields (loc ptr + i64 len, the module's own string, like the
shadow frame's), stores emitted at emit_restart_case in both backends, a
[flan_restart_loc] accessor, the agent snapshot copying it beside each
name, =restarts= growing a loc column, and the buffer printing
"0: [continue] sand.flan:52". Cross-backend ABI change; do it as one lane,
not as a rider.
- A site for user =error= calls. flan_error has no loc parameter; threading
one through means both backends' call emission. Same lane as above if the
frame is being touched anyway.
* The INSERTIONSORT crash, 2026-09-20 — bytes copies, rodata traps, segfaults park
** What happened
The author dogfooded an in-place sort over (bytes "INSERTIONSORT"). (bytes s)
was a zero-cost reinterpret — the [u8] aliased the string's storage — so the
sort wrote into a string constant. The compiled build appeared to carry on
(measured: at -O2 LLVM deletes the store as UB, so the program silently does
nothing; at -O0 both backends already emitted the data read-only and the
store trapped). The dev session hard-crashed with no message at all: the
merged daemon runs the program's code in its own process, so the SIGSEGV
took compiler, socket and session down together.
** The decisions, in the author's words
1. "I would expect bytes to copy, but there should be an equivalent slice
function for read-only." — (bytes s) now allocates a writable copy of the
string's bytes; (bytes-view s) is the old free reinterpret, read-only by
convention. (string b), the mirror reinterpret, is unchanged.
2. "Don't we have allocators for this sort of thing?" — the copy goes
through the allocator surface like every allocating operation: (bytes s)
takes the context allocator, (bytes s a) names one, failure signals
StorageExhausted with retry, and dev builds note the block in the
allocation registry. Never a hidden malloc.
3. String constants are read-only on every path — LLVM `constant` globals,
x86 .rodata — so a stray write traps immediately and identically at -O0
on both backends and in the session (pinned in test_acceptance.ml; the
-O2 store deletion is UB and is documented, not pinned).
4. A segfault in a dev session is a stop, not a silent death: dev builds
install a SIGSEGV/SIGBUS handler (flan_dev_crash_enable, constructor
emitted only in dev builds) that names the address and the innermost
frame, then parks in the break loop through flan_trap_hook exactly like
the no-channel traps — the daemon stays alive, describe answers
:condition "SegFault", evals still run. Release builds are untouched.
** Found by review, fixed on the same branch
- The park had the original bug inside it. sigaction without SA_NODEFER
blocks the handler's own signal for the whole handler, and here the
handler *is* the park — it never returns. A hardware SIGSEGV delivered
while SIGSEGV is blocked is not handled at all: the kernel forces the
default action. So fault, park, evaluate something at the break loop that
faults, and the daemon died exactly the way the author's session did.
Measured both ways before and after the flag. SA_NODEFER added,
flan_crash_entered cleared before the hook so each break-loop fault gets
its own line, and the case is pinned (trap_park ~refault:true) — the pin
was confirmed to fail without the flag rather than pass vacuously.
- A disposition is per process, and a merged `flan dev' is one process with
the daemon in it: the handler was shadowing OCaml's SIGSEGV handler for
the daemon's whole life, including after the program run ended, which
turns a daemon-side stack overflow into a park instead of Stack_overflow.
Scoped to the thread it was armed on; other threads chain to whatever was
installed before. Arming per *run* was considered and is wrong — a
finished program still runs Flan from flan_merged_park's poll, so every
C-x C-e at the parked prompt would have been left unprotected. The thread
test also makes the per-thread sigaltstack honest, since only the armed
thread has one.
- wasm32 compiles flan_dev.c and has no signals; the section is guarded and
flan_dev_crash_enable is a no-op there.
** Open directions left here
- Read-only slice types. bytes-view is read-only *by convention* only: the
type system has no way to say a [u8] cannot be stored through, so the
rodata trap is the enforcement. A read-only slice (or provenance) is what
would move that refusal to compile time.
- (clone slice) / (clone slice a) as the general spelling of what (bytes s)
does for strings. Not done now: clone answers its argument's type, and a
cloned [u8] would be a block with no owner — the same who-frees question
bytes answers by leaning on free-all/destroy. If slices grow a clone, the
two should share the lowering (flan_bytes_dup already is it).
- The bytes copy is reclaimable only by its allocator's free-all or
arena-destroy — the slice carries no allocator, so (free) cannot take it.
Fine against an arena or the frame allocator; a heap-tier copy is a block
that lives until exit. Documented in BUILT.md's surface table.
* slice's arities, and at/slice over a string — 2026-09-20
The two rulings, in the author's words:
#+begin_quote
slice should take multiple arities, none just pass the whole slice, 1 start
from n, 2 n to m
#+end_quote
#+begin_quote
at/slice should work on strings.
#+end_quote
Both came out of the same wall: a fixed array does not decay to a slice at a
call, so handing [6 2 4 9 1 9 4 5] to a generic sort meant writing
(slice a 0 (len a)) every time; and a string could be neither indexed nor
sliced at all, so the only route to a byte was (bytes s) — which the lane
changing bytes into a copying operation would have turned into an allocation
per index.
What landed. (slice a) is the whole of it and (slice a n) is the tail from n,
written out in check.ml into the three-argument form — same node, same static
bound checks, same runtime trap, and on a fixed array the implicit length is
the constant (len a) already folds to. A target that is not already a name
goes through a slot first, so (slice (f x)) calls f once. Neither backend
needed arity work.
Strings: (at s i) is the byte, bounds-checked, and (slice s ...) at all three
arities answers a *string* viewing the same bytes — not a [u8], because a
byte slice is writable-looking and these bytes are not the program's to
write. (set (at s i) x) is refused in check_place and says so. The backends
needed one case each: emit.ml's element_addr grew the String arm, and
x86.ml's index_len grew the length it checks a string index against — it had
been returning None, so x86 would have indexed a string with no check at all
once the checker allowed it.
Open, and not invented here: (slice s) cannot be passed to a [u8] parameter.
Crossing wants bytes-view, which is the other lane's to land.
** Review follow-ups on the same lane
Found by the independent review of this branch against dev-loop, and all of
it fixed here rather than queued.
The blocker was an interaction and not a bug in either half. dev-loop's
single-index fast arm (ab94c69) checks its own target and calls [indexed]
directly, on the stated grounds that [indexed] refuses a string by name —
which was true until this branch made [indexed] accept one. A refusal in
[check_place] therefore covered the spellings that go through it —
(set (at g 0 0) x) and (addr (at s 0)) — and missed the one a person
writes: (set (at s 0) 90) compiled, LLVM dropped the store and x86 exited
255. The question now lives in [indexed] itself, behind a
~place location, and is asked at every dimension — (at g 0 0) over a
[[2 string]] reaches a string only at the last step. [refuse_string_place]
is the one message, and [addr] gets it too, so it reads as value-versus-place
rather than as an assignment rule.
Slicing an array a call returned is refused outright now, at every arity.
It dangles — the view outlives the temporary, both backends print reused
bytes, nothing traps — and it dangled the same way at (slice (mk) 0 3) long
before this branch. It was cheap to refuse and nothing in the tree did it.
An array literal is untouched: the frame holds one for as long as the form
it is written in.
Noted, not fixed:
- A sliced string loses the trailing NUL both backends emit after a string
constant. The contract is ptr+len and nothing promised otherwise, but a
declare-c wrapper that leaned on the courtesy is now leaning on a slice's
end.
- (at d i) over a dyn string works and (slice d 1) is refused. Pre-existing,
and semantics never fork, so dyn slice should exist.
- (slice "abc" 0 99) is not refused at compile time, because Types.String
carries no length. Consistent with a slice of a slice; a missed nicety.
* 2026-09-20 — an evaluated expression that signals says so at once
An expression evaluated from a buffer signalled a [BoundsError], the thunk
stopped in the break loop, and five seconds later the daemon answered "the
program did not reach a frame boundary; is it calling (agent/poll)?" — on a
reply that said [:stopped t :condition "BoundsError"] two fields along. The
program had reached the boundary, run the thunk, and stopped inside it.
[eval_expr]'s wait recognised exactly one kind of stop: a [Pause], and only
when [:pause t] had asked for one. Every other stop fell through to the
timeout arm, which then chose between two sentences neither of which was about
a thunk sitting in the break loop.
** The decision
The wait reads what it found on the way in, before the module is delivered,
and treats a stop entered after that as the thunk's. A fourth answer carries
the condition's name off [status] and is given at once, because a thunk in the
break loop will never produce a value on its own and waiting for one is
waiting for nothing:
the expression stopped on BoundsError before it produced a value. The break
loop is holding it: take a restart, or abort
5.15s to 0.01s on the reported case.
"After that" is a *number*, not a name. [stop_gen] (lib/dev.ml) asks the
agent's [stop] verb, which answers [snap_top()->gen]; [snap_push] mints one on
every break entry including a nested one, never reuses it, and runs on both
backends. So the case a name cannot settle — evaluating from inside a break
into a thunk that stops on the same condition class — is settled by comparing
two integers. No new C: the verb is there, and the writable inspector two
screens down in the same file already uses it for the same kind of question.
The name stays as the fallback for an agent that cannot answer [stop], and the
old ambiguity comes back only there.
Both timeout sentences stay as they were, and are now only said when they are
true — the frame-boundary one when the program is running, not parked, and
silent for five seconds.
The [(pause)] path is not untouched, and saying so would be wrong. The split
is on the condition's name now, not on the [:pause t] flag. A plain [C-x C-e]
over an expression that calls a body somebody marked with [C-u C-c C-c]
reaches a [(pause)] this request never asked for, and used to spend five
seconds and then blame [(agent/poll)]. It answers [ok] with "stopped at
(pause)" at once, the same as the flagged case. Pinned in test_dev.ml on the
[dev-pause] daemon, over a function the program itself never calls.
Which is why [(pause)] is not folded into the signalled arm. A breakpoint
firing is the feature working, and "the break loop is holding it: take a
restart, or abort" would be telling somebody to abort out of the breakpoint
they set on purpose. The flag was never what made a [(pause)] deliberate —
putting one there was.
[:pause t] itself is unchanged in every case that exists. [Session.eval_expr]
splices the call *ahead* of the expression ([Do [pause_call; parsed]]), so a
flagged thunk always stops at its [(pause)] before the expression can run, and
"it signalled before reaching the pause" describes nothing reachable. An
earlier draft of this entry claimed otherwise.
Cost, measured rather than assumed: the wait now asks [status] every tick
where it used to short-circuit on [pause &&] and ask nothing. 22µs a round
trip under [--two-process], 22ms over the thousand ticks of a full timeout,
against a five-second budget — and [ms - 5] counts ticks, so that is budget
inflation rather than time spent inside it. Four parts in a thousand. Left
alone.
[run_render_thunk] shares the sentence and is stopped-only by design, with its
own [resumed] discriminator. Untouched. Emacs needed nothing: [flan--absorb]
already reads [:stopped]/[:condition] off every reply including errors, and
already schedules the break buffer.
** Open: whose break it is, which no counter answers
The generation says a break is new. It does not say whose.
[build_module] takes a couple of hundred milliseconds between the snapshot and
the delivery, and the wait runs for five seconds after it. A game loop that
signals on its own during either window bumps the generation exactly as a
thunk would, and the reply then says "the expression stopped on X" about an
expression that had not run. The machine-readable fields stay right — the
editor opens the break the program is really in — so what is wrong is the
sentence and only the sentence.
Nothing counted can close it, because the program's break and the thunk's are
the same kind of event. What separates them is the per-frame "program"/"eval"
origin the backtrace already carries, and that is LLVM-only: the default x86
backend pushes no shadow stack, so on the backend [flan dev] actually gives
you, it answers nothing. Closing it properly means x86 pushing frames in dev
builds, which is a lane of its own.
Not queued. The window is narrow, the fields are right, and the wrong sentence
is a great deal better than the one it replaced.
* def, and defvar renamed to defonce, 2026-09-20
** The gap, hit dogfooding
The author edited a ~defvar colors [4 u32] [...]~ initialiser and the colours
did not change on C-c C-c — which is defvar working exactly as designed, and
the wrong form for what he was doing. The re-run rule (the entry "Per-form
initialisation semantics on re-run" above) had already decided the trio in
his words: "It doesn't matter if you rerun that startup function, those 3
forms decide what happens." Two of the three were built; ~def~ — foreseen
there as "a def-style form that re-evaluates, recomputes on every run" — was
not. Now it is.
** The rename, author's words
"Rename to defonce, I think Clojure's name is better and more descriptive."
Clojure's ~defonce~ has exactly these semantics — define only if unbound — so
the name now says what the form does, next to a ~def~ that follows the
source. ~defvar~ is refused by name (~parse/defvar-renamed~) with the two
spellings that compile; every program, test, doc and editor list in the repo
is swept, except sand.flan, which is the author's live WIP and stays for the
merge.
** The trio
| form | initialiser runs | on a re-run |
|----------+-------------------------+------------------------------------|
| def | at startup, every run | repaints — the source's value wins |
| defonce | at startup, first run | keeps — the program's value wins |
| defconst | never — it is the image | untouched; it was never storage |
~def~ is Common Lisp's ~defparameter~, ~defonce~ is CL's ~defvar~ under
Clojure's name, and ~defconst~ is a compiler constant. Every spelling of the
third element (the four spellings pinned in test_flan.ml) holds for ~def~
exactly as for ~defonce~ — same parse arm, same collision rules, same
third-element dispatch — with one field of difference, [Ast.reinit], carried
to [Tast.global]'s [grerun].
** How a def reaches the next re-run
Two mechanisms, one per half of the promise:
- No guard flag. [Emit.startup_plan] gives a [defonce]'s computed initialiser
the [.init~once.] flag and gives a [def]'s none, so the store runs on every
entry into main. Shared plan, so the two backends cannot disagree.
- Always lifted. [Check.check_global] lifts *every* def initialiser into
[global/<n>] — zero and literal included, where a defonce keeps constants
inline in the image. The host's startup calls the initialiser through its
function cell, so when the author edits the form and C-c C-c's it,
[Session]'s [def_inits] hands [global/<n>] to the redefinition, the cell
swaps, and the next re-run stores the *edited* value into the same storage
— a native ~[4 u32]~ def repaints in place, so every reference sees the new
values. A constant left inline would have baked the stale value into the
host's startup body for ever. [Emit.redefinition] declares the cell for a
non-sibling target (a lifted initialiser's [fparent] is its global); the
x86 side already reached host cells through the GOT.
~uninit~ is the one exception on both forms: nothing to run, nothing to lift,
nothing repaints.
Pinned in test/programs/dev-rerun.flan (a ~(def c 3)~ printing 4, 4, 4, 4
beside a ~tally~ climbing 1..4; a ~(def hues [4 u32] ...)~ element printing 8
every run; then ~(def c 9)~ evaluated and the next re-run printing 10 while
the defonce beside it climbs on), in test_flan.ml (parse shapes for every
spelling of both forms, [grerun] on the pair, the lifted-initialiser claim,
def/defn and def/defonce collisions, the defvar teaching error verbatim), and
in programs/global-init.flan's last four lines (all def spellings start
identically on both backends and at both -O0 and -O2).
** Review follow-ups
Three real defects, all from the always-lift, all found by review rather than
by the suite:
- *A def typed fresh into a live session came up zero and stayed zero.* The
image [flan_dev_global] copies on the allocation is the only value a
brand-new global ever gets — the host's [.init-globals] was compiled when
the process started and never calls the new name's initialiser — and both
backends decided that image with [Tast.const_init g.ginit], which a def's
lifted [Call] fails by construction. [(def n i64 42)] therefore came up 0
where [(defonce n i64 42)] came up 42, permanently, for that process. Now
[Emit.initial_image] reads the constant back out of the lifted body and
both backends ask it; the x86 twin had the same bug and the same fix.
Pinned in test_session.ml beside the defonce row it is compared against.
- *Changing the keyword on an existing global was silently ineffective.*
Which form declared it is not in the storage, it is in the startup
function's guard, compiled into the host. A redefinition republishes the
initialiser and cannot republish that, so defonce→def kept the guard and
never re-ran, and def→defonce kept re-running. [Session.compatible]
refuses both ways now and says to restart. Editing the *value* is the
workflow and stays allowed, which is the row beside it.
- *Swapping in or out of defconst was silently accepted, and one direction
did damage.* The first cut of the refusal above asked only about the two
mutable forms, and claimed in its own comment that a defconst on either
side was another arm's business. It was not: [defconst x] → [def x] at the
same type fell past every arm, and [defonce x] → [defconst x] fell past
them into the [consts] republish, which stores the declared value over the
storage at the frame boundary — "edit the code, keep the sand" undone by a
keyword. One refusal over [gconst] and [grerun] together now covers all
six directions, which is right because it is one fact: the defining form
is fixed at build time.
- *[global/<n>] leaked into a user-facing refusal.* Retyping a def hit the
function-signature arm first, which answered about [global/paint] — a name
nothing in the source mentions. The lifted initialiser is skipped there
now; the global loop below says the same fact in the words a reader can
act on.
Also covered, having been reasoned rather than exercised: a def whose type
changes between re-runs (the "changes type" refusal); a def initialiser that
reads another global at run time and re-reads it on each re-run
(dev-rerun.flan's [echo], which follows [counter] at 40, 41, 42, 43 where a
captured first answer would print 40 four times); and the x86 half of the
new-global image, which reload-v6.flan now *runs* rather than greps — a
[(def dial i64 5)] the host was never built with, whose 5 shows up in the
transcript's arithmetic.
** Red on this branch, for the merger
sand.flan spells ~defvar~ at lines 15, 16, 24, 25, 26, 115 and 116 and was
not touched — same situation as the raylib keywords above. The three
sand-dependent tests (test_flan's parse pin, test_session's create,
test_acceptance's "a package's main is not visible") are red here and turn
green when those seven lines say ~defonce~ (or ~def~, where the author wants
the initialiser to follow the source — ~colors~ was the motivating one).
* dotimes counts, 2026-09-21
"Is there a way to do dotimes or a loop in reverse?" — the answer was a
hand-written let plus set, which is the wrong answer for the commonest loop
there is after counting up.
Ruled: dotimes grows the start/stop/step arities, the way CL's loop and
Clojure's range have them.
(dotimes [i n]) ; 0 .. n-1, unchanged
(dotimes [i start stop]) ; start .. stop-1
(dotimes [i start stop step]) ; start, start+step, ... while short of stop
stop is exclusive in every arity, so (dotimes [i 0 n]) is (dotimes [i n]) —
one rule, not two — and a negative step counts down, testing with > instead
of <. (dotimes [i 9 -1 -1]) is 9 down to 0.
The two edges, decided: a literal step of 0 is refused at compile time, being
an infinite loop spelled as an accident; a step that is only a value cannot
be refused there, and the sign test that picks the loop's direction leaves 0
with neither direction, so it runs no times at all. Terminating and
deterministic, and it costs nothing — a literal step still emits the one
comparison it always did.
Still a special form desugaring in check.ml to a Let and a While, so neither
backend learned anything. test/programs/dotimes-range.flan is the corpus
program; docs/BUILT.md carries the convention.
Four sites, all of them this feature and none of them a pre-existing bug:
parse.ml takes a vector of two to four, check.ml desugars it, and load.ml's
two walks — the Ast rename and the Form-level one at load.ml:503 — learn to
walk more than one bound. The Form walk matched Vec [n; count] exactly, so
it had to grow; before this, a three-bound dotimes was a parse error long
before that walk could see it, so nothing was ever miscompiled by it.
* 2026-09-21 — a restart that abandons the evaluation
The report, in the author's words:
I got an error evaluating the insertion-sort, but it killed the whole flan
program, the "continue" restart didn't work, I kept pressing 0 and nothing
would happen, there's a design problem here, likely because it's running on
the same thread as the game loop? What's going on? It should just be able to
ignore that whole call
The last sentence is the requirement, and it was the one thing the break loop
could not do.
** What was actually wrong
Reproduced headless, both backends, on a running program: evaluate an
expression that indexes past the end, and the break it lands in offers
restarts ()
or, over a program whose own loop holds a [restart-case], a single entry below
the thunk boundary and marked unreachable. Nothing takeable either way. A bad
index establishes no restart of its own — nothing a handler could do would make
index 9 valid for a length-4 array — and the program's own [continue] is below
[flan_reload_call], which holds its own transfer channel and drops it on
return, so a transfer to it has nowhere to land. Which left [abort], and abort
is [_exit(134)]: in a merged [flan dev] that is the compiler, the session and
the game, over a mistyped index.
So the floors were right and the list they produced was empty. Everything the
agent knew how to say about that break was a refusal.
** Not the threading, and the question deserves a straight answer
The thunk does run on the game thread. That is the design and not an accident:
the break loop *is* the poll loop, which is the only reason C-x C-e works at
the moment anyone wants it to, and [restart_floor] is documented game-thread-
only for it. But it is not the cause. A thunk on a thread of its own would have
had exactly the same empty list and exactly the same [abort]. What was missing
was a restart, not a thread.
** The decision
The agent establishes one restart of its own around every evaluation:
0. restart: abandon-evaluation (stop running the expression; the program
carries on)
It is a real frame on the real restart list, pushed by [flan_agent_poll]
immediately after the floor is read — which is what puts it *above* the floor
and makes it reachable, where pushing it first would have marked it as the
program's and refused it. Taking it aims the transfer at that frame; nothing
compares against it, so the unwind runs to the top of the thunk,
[flan_reload_call] drops the channel it holds, and the poll returns to whatever
called it. That is the same path a below-the-floor restart used to take by
accident. The difference is that this one is what was asked for, and is
reported as what happened.
The frames live in flan_rt.c ([flan_restart_push_c]/[flan_restart_pop_c]),
because the struct is declared there and two files each declaring it is how the
two stop agreeing. A fixed array of sixteen, not malloc: this is pushed on the
game thread at a frame boundary.
No codegen. Both backends unwind by the same convention, and both were driven
end to end.
** What it does not promise
Abandoning drops the expression. It does not undo it. The thunk ran until it
signalled, and every global it set and every byte it allocated on the way is
still set and still allocated. Said in the agent's line, in the daemon's reply
note, in the break buffer's row and in MANUAL.md, because an editor that said
only "abandoned" would let someone believe the program is where it was before
they pressed C-x C-e.
** The other half: "I kept pressing 0 and nothing would happen"
A choice a reader makes has to do something or say why it cannot.
[:unreachable] was already on the wire and the break buffer was not reading it:
it drew every restart as an ordinary takeable row, and a digit on one sent it
to the daemon to be refused. The row now loses its bracket, carries the reason
beside it, and is refused *here*, out loud, with the sentence the daemon would
have given. [:abandon] is new beside it — the position that abandons, [nil]
when the break is not inside an evaluation — and it is a position rather than a
name on purpose: a program is free to establish a restart called
[abandon-evaluation] of its own, and matching on the name would offer the
program's restart as the way out of an evaluation. The agent identifies it by
frame address; the wire carries it as a third value of the flag [restarts]
already had, [*] beside [+] and [-].
Point in the break buffer starts on that row, and [C-c C-M-b]'s prompt takes it
as the default. The list is not reordered — the number beside a restart is the
program's own index, and moving rows would make the numbers lie. [abort] stays
last and stays not-the-default.
** What still cannot be abandoned, and correctly
A trap has no transfer channel at all — [rt_trap] calls the hook with nothing
to write a frame into — so at a trap every restart is refused, the boundary's
included, and [:abandon] is [nil]. There is nothing to unwind through. The
break is still a place to stand and read; fix and reload is the way out. That
is the one case where "it should just ignore that whole call" cannot hold.
** Open: nothing counts abandonments
An earlier draft had the agent count them and a [abandoned] verb to read the
count, so a daemon waiting on a value could end its wait. It is not needed: the
wait already ends the moment the thunk breaks (see "an evaluated expression
that signals says so at once" above),
and the restart's own reply says what taking it did. A third telling read by
nobody is how a wire grows a verb whose answer drifts from what happened.
** Review follow-ups
Five things the review found, and one it asked me to judge rather than take.
*Two refusals, not one.* At a trap nothing on the list can be taken — the
break has no transfer channel at all — and the daemon was folding that into
the same [:unreachable] it uses for "below the evaluation this break is
inside". The break buffer then captioned a segfault's restarts with a sentence
about an evaluation that was not there, and offered no way out of one. The
terminal listing had always said the two apart; the wire had not, which is
exactly the divergence the [restarts] comment says must never happen.
The trap now rides on the break reply as [:trap], and both the caption and the
refusal branch on it. It is a bare [!] line ahead of the entries rather than a
fourth flag value, because it is a fact about the *break*: a trap with an empty
restart list — dev-trap-null-alloc is one — has no entry to carry a flag, and
that is the case that has to be able to say so. It is *not* inferred from
[:abandon] being nil: a break the program took on its own has a nil there too,
and so does a truncated list.
*The escape hatch survives truncation.* [snap_push] walks innermost first and
stops at SNAP_MAX or SNAP_NAMES, so the outermost entries are what truncation
drops — and the boundary is the outermost entry of an evaluation, which made it
the first casualty. A thunk establishing 64 restarts of its own reproduced the
original bug exactly. One slot and one name's worth of bytes are now kept back
and the boundary is placed in them when the walk does not reach it. The cost is
one listed restart out of sixty-four while an evaluation is in progress.
*[flan_break_resume] is gone.* Nothing has called it since the break loop
started choosing by position instead of by name; NEXT.md already said there was
no such function. Two ways to resolve a restart that can only ever disagree is
one too many.
*[eval_boundary] is cleared between runs.* [flan_agent_run_reset], called
beside [flan_condition_stacks_reset] and [flan_dev_frames_reset] from the park.
Harmless today — a program's restart frames are allocas and can never compare
equal to a stale one — and the floors go with it, because emptying two thirds
of the same state is stranger than emptying none.
*Nested boundaries are tested rather than reasoned about.* Two evaluations, the
second run from inside the first one's break, on one list: six restarts, the
inner boundary at 2 and takeable, the outer one at 5 with the frames it belongs
to below the floor. Abandoning the inner leaves the outer with its three
restarts and its own boundary still on offer. That is the claim the save-and-
restore around [j.call] exists for.
*The round trip, judged and removed.* [choose_at] and [choose] each asked
[restarts] before sending, only to word their note — doubling the traffic of
the verb somebody is actually waiting on, to learn something the other end had
in front of it. The agent now answers [ok abandon] for the boundary and [ok]
otherwise. It could not simply be read off the [break] reply the client had:
the daemon words the note and the daemon had not seen that reply. Saying it on
the acceptance also closes the window — after a take the stopped thread
resumes and the snapshot it was resolved against is popped, so there is nothing
left to ask.
* The enum prefix goes uniform, 2026-09-21
** The ruling, author's words
"I think the prefix reads better, keep it." So it stays, and it stops being
a thing two enums have: two prefixed out of eleven was the inconsistency,
not the prefix. This closes the survey left open under "Enum keyword
prefixes, 2026-09-20" — every enum on that list now has a ruling.
** The table, as applied
| Key | KEY_ | ~key-~ |
| MouseButton | MOUSE_BUTTON_ | ~mouse-~ |
| TraceLogLevel | LOG_ | ~log-~ |
| CameraProjection | CAMERA_ | ~projection-~ |
| CameraMode | CAMERA_ | ~camera-~ |
| GamepadButton | GAMEPAD_BUTTON_ | ~button-~ |
| GamepadAxis | GAMEPAD_AXIS_ | ~axis-~ |
| Gesture | GESTURE_ | ~gesture-~ |
| MouseCursor | MOUSE_CURSOR_ | ~cursor-~ |
| TextureFilter | TEXTURE_FILTER_ | ~filter-~ |
| PixelFormat | PIXELFORMAT_ | ~pixel-~ |
Two of those are judgement rather than transcription, and both were checked
against the real member lists before being taken:
- CameraProjection and CameraMode share raylib's CAMERA_ and do *not* share a
Flan prefix. They are two different questions asked of the same struct, and
~:projection-perspective~ beside ~:camera-orbital~ says which one is being
answered where a shared ~camera-~ would have left the reader to work it out.
- GamepadButton and GamepadAxis take ~button-~ and ~axis-~ rather than a
shared ~gamepad-~ stem. The two are never in the same position, and the
shorter prefix is what keeps ~:button-left-face-up~ and
~:axis-left-trigger~ readable — ~gamepad-~ on both would have said the part
the surrounding call already says. MouseButton keeping ~mouse-~ is the same
call from the other side: a mouse button and a pad button are different
sets, and the prefix is where a reader is told which.
~log-~ rather than ~trace-~ for TraceLogLevel: the C names are LOG_, ~trace~
is itself a member, and ~:log-warning~ is what the call reads as.
** It is a reading choice, not a collision fix
Worth writing down because the next reader will otherwise assume it was
necessary. A keyword at a call site resolves against the expected type and
against nothing else (lib/check.ml, the ~enums~ table), so two enums may
share a member spelling with no consequence at all — ~:point~ at a
TextureFilter site could never have meant anything else. What the prefix buys
is the call site read on its own: ~(rl/set-texture-filter t :filter-bilinear)~
says which closed set the name came out of, where ~:bilinear~ asked the reader
to know the signature first. docs/BUILT.md says so in the bindings section.
** The round trip, actually run
~flan generate-c vendor/raylib~ is green against raylib-5.5.h with all eleven
third columns in place: 267 declarations, and every defstruct, hand-written
declare-c and mapped constant agrees. The check was confirmed non-vacuous by
breaking it on purpose — ~filter-trilinear~ spelled ~filter-trilinearr~ was
reported as "the header has no constant named TEXTURE_FILTER_TRILINEARR",
which is the prefix being stripped and reapplied rather than a name passing
unexamined.
The three ~constant~ exception lines are keyed on the member's full Flan
spelling, so they moved with it: ~Gesture/gesture-double-tap~,
~PixelFormat/pixel-compressed-astc-4x4-rgba~ and its 8x8 twin.
test/test_flan.ml pins one member of each of the eleven against the C name
the rule reaches, read out of the real vendor/raylib/bindings, plus the claim
that every mapped enum declares a prefix at all.
** sand.flan, and the alias that stood in for it — removed at the merge
The lane could not edit sand.flan, whose line 121 was
~(rl/set-trace-log-level :warning)~, so it left ~warning 4~ beside
~log-warning 4~ in the TraceLogLevel defenum with a ~constant
TraceLogLevel/warning LOG_WARNING~ line to match. The review found the alias
was not inert: ~lib/render.ml~ folds members so the last-declared wins, so a
TraceLogLevel of 4 read back as ~:warning~ in the break loop, the did-you-mean
suggested it, and every TraceLogLevel error listed it among the members.
So it is gone, with the call respelled, at the merge: sand.flan:121 says
~:log-warning~, the ~warning 4~ member and its paragraph are out of
raylib.flan, and the ~constant~ line is out of bindings. Every member of every
mapped enum now carries its prefix, with no exception.
* One slice, and the warning moved to the push — 2026-09-21
The ruling, in the author's words:
#+begin_quote
merge them into one slice. as-slice goes away.
#+end_quote
** Why a second name was the wrong shape
[as-slice] existed as a warning. check.ml said so beside it: a view of a Vec
is a borrow from storage a push, a put or a reserve may reallocate out from
under you — the explicit Zig/Odin contract spec-memory.md chose instead of a
borrow checker — and a different word at the call site was how a reader was
meant to be told.
It does not work, for two reasons and the second is the one that decides it.
The input type already determines the semantics completely. A Vec can only be
borrowed; a fixed array, a slice or a string can only be viewed. There is no
call site anywhere at which a reader would want to pick between the two
behaviours for one input, so the second name expressed no choice. It was a
label, not an operation.
And it was a label in the wrong place. It warns at the moment the view is
taken, which is the one moment nothing is wrong: the view is correct when it
is made. The danger arrives later, at the push. A warning stapled to the safe
end of the story is a warning nobody reads at the unsafe end.
** What landed
One [slice], over a fixed array, a slice, a string and now a Vec, at all three
arities. The Vec's half is [vec_slice] in check.ml; everything the previous
lane built — the backwards-literal refusal, the static out-of-range refusal
where a length is known, the runtime trap, single evaluation of a non-trivial
target, the zero-cost implicit length on a fixed array — is untouched, and a
Vec reaches none of the static ones because it has no static length to reach
them with.
The merge is entirely in the checker. Neither backend has an arity case, a
type case, or a line about this: the Vec path builds the same
[flan_vec_as_slice] call [as-slice] built, and the C symbol keeps its name.
*(slice v lo) was free.* The runtime already reads a [hi] of -1 as "to the
end", which is what the one-argument form passes, so the tail form passes the
caller's [lo] and the same -1. No slot, no length read, no second evaluation of
the target, nothing computed that was not computed before. The Vec had no
two-argument spelling only because the name it had was not the name that grew
the arities.
*A Vec a call returned is accepted*, where an array a call returned is
refused. This lane first refused it, the review pushed back, and the author
ruled:
#+begin_quote
we're purposely doing manual memory management for the static side, so whatever
#+end_quote
[(slice (mk))] over an array dangles: the view outlives a temporary the frame
reuses, and the dangle is the whole reason that refusal exists.
[(slice (make-vec))] does not dangle — the storage a returned Vec owns lives
until its allocator's free-all or destroy, so the view reads what it says it
reads. What a returned Vec loses is the *owner*, and losing an owner is a
leak, which this language has already ruled is defined behaviour:
spec-memory.md on overwriting a global Vec says it "overwrites the first block
and leaks it; there is no drop", and programs/strings.flan says "leaking is
defined behaviour" out loud.
So the refusal singled out one of three operations that lose the same owner.
[(len (mk))] and [(at (mk) 0)] compile and leak the identical block, and
refusing only the third would have been a rule about a spelling rather than
about a hazard. It also broke code [as-slice] accepted, including the case
where there is nothing to leak at all:
[(with-allocator context/temp (println (len (slice (mk)))))] — the arena takes
the block back whatever anyone does with the header. Dropped, and check.ml
says why beside the array refusal it sits next to, so that the asymmetry reads
as deliberate rather than as an oversight.
*The array refusal stays, and the line between the two is the point.* They
look alike and they are not. A view into a returned array points at bytes the
frame has already handed to something else, so it answers a number that was
never in the array — a wrong answer, silently, on both backends, with nothing
to trap on. A view into a returned Vec answers exactly the elements it says it
does; the cost is a block nobody can free. Wrong answers are the compiler's
business and leaks are the program's, which is the whole of why one refusal is
kept and the other is gone. BUILT.md and spec-memory.md both say it, because a
reader meeting one of the two forms will assume the other behaves the same
way.
The refusal for the name itself is in [ordinary_call], after every table, so a
program that defines an [as-slice] of its own still reaches its own. It reads
for somebody who has never heard of the old name — "there is no as-slice" —
and writes the call back out with the arguments the reader wrote, spelling any
argument that is a name or a number and standing in for one that is not, so
the suggestion is always a form that compiles.
** The warning, moved
docs/BUILT.md gains a section next to the Vec surface table, and the [push]
row points at it: a view of a Vec is invalidated by [push], [put] or [reserve],
nothing checks it, and the rule is to take the view again afterwards.
spec-memory.md's "Borrowing" says the same in its own register and drops the
old two-spelling line.
** Investigated and NOT built: a live view at the push
The brief asked whether a [push] with a live view of the same Vec in scope is
detectable cheaply, and said to build it only if the obvious case is catchable
with no false positives. It is not, and the reason is not analysis cost.
The decisive case is one line of *correct* code:
#+begin_src flan
(reserve v 100)
(let [s (slice v)]
(push v 1)
(println (at s 0)))
#+end_src
The reserve is exactly how a program says "this push will not reallocate", and
under the contract the spec chose that promise is the program's to make. Any
flag on this — error or warning — is a false positive by the language's own
semantics, not by an approximation the check settled for. The bar the brief
set therefore cannot be met by a cheaper check, because the obstacle is not
precision.
The syntactic sketch is worth writing down so nobody re-derives it. To avoid
flagging the common and harmless [(let [s (slice v)] (println (len s))
(push v 1))] — where the view is dead by the push — the check must find a use
of the view *after* the push, which is liveness. Textual order is not
execution order across an [if] or a loop; a [set] of the view's binding or a
shadowing of either name breaks it; and narrowing it to one straight-line
statement list to make the order real shrinks it to almost nothing. Meanwhile
static flow tracking was deliberately repealed on 2026-09-18, and this is a
borrow checker's question wearing a smaller hat. Two reasons to stop, and the
first one is sufficient on its own.
What was built instead is the sentence, in the two places a reader meets the
operation that breaks the view.
The independent review sharpened the argument and reached the same place. The
reserve witness kills the cheap per-push flag. The refined version — flag a
push only when no reserve on *that* Vec came between — has to know which Vec a
view was taken from and what happened to it in between, across calls and
control flow, which is the static flow tracking repealed on 2026-09-18.
Witness kills the cheap check; repeal kills the sound one.
** Two forks closed on review, and one refusal dropped
Found by the independent review of this branch and fixed here.
*The -1 sentinel was reachable from user syntax.* [(slice v 0 -1)] answered
the whole Vec and [(slice v 1 -1)] the tail, on both backends, while
[(slice a 0 -1)] over an array was refused as a negative bound — the same
builtin giving the same literal opposite meanings. The refusal now runs on the
bounds the reader *wrote*, before the implicit hi is built, which is the only
order that works: the sentinel is itself a -1, so a check on the finished pair
would refuse [(slice v)] itself. [(slice v -1)] is a compile error now, in the
same words an array gets. The backwards-pair check moved into the branch where
both ends are bounds somebody wrote, so it no longer has to step around a
value nobody wrote.
*The bounds fork is closed toward [index_expr].* The array path expected an
i32 outright and the Vec path used [index_expr], so with a u32 in hand
[(slice v c)] compiled and [(slice a c)] did not. The tiebreaker is not which
half is older but what every other subscript in the language does: [indexed]
and [vec_at] both take their index through [index_expr], so [(at a c)]
compiled where [(slice a c)] did not — the fork was between [slice] and [at]
as much as between two targets. A bound is a subscript; it takes the subscript
rule. Nothing is loosened that the bounds check does not still catch, and i64
and u64 are still refused by name on both paths.
*The returned-Vec refusal is dropped*, as above.
** Swept
Every spelling in the repo: lib/ (check.ml, prelude.ml, render.ml, shim.ml),
runtime/flan_rt.c comments, test/ (test_flan.ml, test_acceptance.ml,
test_valgrind.ml and fifteen programs), vendor/edn and vendor/json, web,
docs/BUILT.md, docs/overview.md, docs/SPIKE-DYNAMIC.md, spec-memory.md,
NEXT.md and syntax-sketch.flan. sand.flan never used it.
* A conversion under a bound, 2026-09-21
** The report
~(defn total [xs [$t]] i32 {:where [(integer? $t)]} ... (i32 (at xs i)) ...)~
was refused with "i32 converts a number, found t". The bound says every type
the body is copied at is an integer, an integer is a number, and the
conversion is exactly what the bound exists to license.
** The root cause, and it is narrower than "predicates are not consulted"
Arithmetic, comparison, min/max, the bitwise fold and the shifts all ask the
bound — each pairs an ~unconstrained ... ~needs:~ call with an ~|| generic_ty~
escape, and the shifts had already been taught ~integer?~. The conversions
were the family nobody had gone back to. Three arms in lib/check.ml, all in
the cast block:
1. ~is_cast~ (a machine type in head position) asked ~Types.is_numeric~ of the
operand and nothing else, so a variable — which has no type yet — fell
through to the refusal. This is the reported bug.
2. The enum target asked ~Types.Int _~ of the operand, the same way, so
~(K n)~ inside a generic body was refused however the variable was bounded.
3. The *variable* target — ~(t x)~ — had the opposite defect. It asked the
bound of the target and then accepted any ~generic_ty~ operand, so a
second variable declared only ~ordered?~ passed the abstract pass on the
strength of a sentence about a different variable. That one was an
acceptance, not a refusal, and tightening it is part of this entry.
It was not reachable, and saying so is the honest version. ~ordered?~ is
~Types.is_comparable~, which admits numbers and enums and nothing else,
and every one of those converts at the concrete arm; a string is refused
at the instantiation before any of this. So no wrong program was ever
compiled through it. What it was is a hole that opens the day ~ordered?~
admits a type that does not convert — which is the same future the rule
below refuses to bet against, and the best evidence for it: a check keyed
to the set a predicate denotes today is correct today and silently wrong
later, where one keyed to what the predicate claims stays correct across
the widening.
** The rule
A conversion is legal at a bounded variable exactly when it is legal at every
type the bound admits, which is the repo rule that generic and concrete code
compute the same thing, applied to a set instead of a type. Read off the
concrete arm, that gives one predicate per target:
- A machine-type target needs ~numeric?~. Every type it admits converts to
every numeric target today.
- An enum target needs ~integer?~. ~numeric?~ admits f32 and f64, and the
concrete arm refuses a float to an enum — sub-decision 3 of the cast block.
- ~ordered?~, ~equal?~ and ~hashable?~ admit nothing.
And per predicate:
- Under ~integer?~ every conversion is legal, the float targets included.
~(f64 x)~ at an unknown-width integer is *not* value-preserving — i64 to
f64 rounds above 2^53 — and it is allowed anyway, because the written
~(f64 i64-x)~ is allowed and a conversion has never claimed the value
survives. Refusing it at the variable would make the generic stricter than
the code it is copied into, which is the fork the rule forbids.
- Under ~numeric?~ every conversion to a number is legal, ~(i32 x)~ included,
and it may truncate a float. Same reasoning from the other side: ~(i32
f64-x)~ truncates towards zero where the type is written, so the bound
cannot refuse what the copy would accept. The enum target is the one thing
~numeric?~ does not buy.
- Under ~ordered?~ or ~equal?~ alone, refused.
That last one is the only place the "legal at every admitted type" test does
not decide it, and it is worth naming rather than hiding. ~Types.is_comparable~
admits numbers and enums and nothing else today, so every type ~ordered?~
currently admits does in fact convert — the test taken literally would allow
it. It is still refused, because the predicate is a claim about ordering and
not about numbers: the day ~ordered?~ admits strings by a chosen collation
(plan.org, Types leaves that open), a conversion keyed to it would silently
start meaning something else. Predicates gate operations by what they say,
not by the set they happen to denote this week. ~hashable?~ makes the point
without any argument at all: it admits strings and structs now.
** The diagnostics
The old line named the variable by its bare spelling, said only what was
wanted, and said nothing about the clause the reader would have to edit —
"i32 converts a number, found t" against a body whose signature says ~$t~.
The refusal now says what the variable is known to be and what to write:
i32 converts a number. The where clause says t is ordered?, and that does
not make it a number — add (numeric? $t) to the where clause
i32 converts a number. Nothing here says t is a number — write
{:where (numeric? $t)} at the head of the body
K converts an integer to an enum. The where clause says t is numeric?, and
that does not make it an integer — add (integer? $t) to the where clause
Both spellings compile as written, and the clause is spelled the way
~unconstrained~ already spells it so the family says it one way: a body with
no clause is handed the whole clause, a body that already has one is told
which predicate to add rather than a clause that would drop the predicates it
has.
** Tests
test/test_flan.ml pins each conversion generic and concrete side by side —
the narrowing i32, the widening f64, an unsigned target, and the enum
direction — and each refusal against its whole message.
test/programs/int-generic.flan runs the reported program and its concrete
twin at -O2 and -O0. No x86 row was added: the checker decides more programs
are legal without changing what any of them emits, and the Cast they emit is
the one widening.flan already pins on x86.
** Left alone, found while here
- ~(total v)~ where v is ~[3 i32]~ and the parameter is ~[$t]~ is refused,
and a written ~[i32]~ parameter refuses the same array with the same
reasoning. Not a fork; an array is not a slice, and the slice/as-slice lane
owns whatever changes there.
- ~i64->bytes~ takes its argument at ~~want:(Types.Int Types.I64)~, so a
written i32 or u8 is *accepted* — implicit widening reaches it — while a
~$t~ under ~integer?~ is refused. That is not a fork either, and the reason
is the rule rather than symmetry: ~u64~ is refused concretely ("neither
widens into the other, so the conversion has to be written"), so ~integer?~
admits a type at which the conversion is illegal, and a bound that admits
one such type cannot license the operation. ~bytes->i64~ has no argument
type to disagree about. Untouched.
- ~print~/~println~ over a bounded variable already defers to the
instantiation and needed nothing.
** Open: there is now no generic enum → integer conversion
Worth recording as a loss rather than leaving the next reader to find it.
None of the five predicates admits enums while licensing a cast — ~numeric?~
excludes them, and ~ordered?~ and ~equal?~ admit them but no longer convert.
Before this entry the one spelling that worked was ~(t x)~ with the *target*
bounded ~ordered?~, through the arm item 3 above closes, so the loss is real
and removing it is still right: it worked by not asking about the operand at
all.
~enum?~ is the eventual answer and it is not a one-liner, which is why it is
written here rather than done here. It entails ~ordered?~ and ~equal?~
enums compare and are equal — and it does *not* entail ~numeric?~, because
arithmetic on an enum is refused where the type is written. So the cast rule
stops being one predicate per target and becomes a disjunction, ~numeric?~ or
~enum?~ for a machine-type target, and the refusal has to name whichever one
the reader meant. A cast *to* a variable bounded ~enum?~ is a second question
with its own answer. Each of those is a decision, not a fill-in, and the
author has not been asked.
* Generic allocation, 2026-09-21 — the sigil, not the feature
Reported as "generic code cannot allocate a container of its own element type":
(vec-new $t) inside a generic body was refused with "nothing here says what
(vec-new) is a Vec of".
The feature was already there. [type_named] and the cast arm asked
[List.mem n env.tyvars] / [List.mem_assoc n env.subst] of the name as *written*,
and those two tables are keyed on the *bare* name — [signature_tyvars] strips
the sigil when it records a variable, and [resolve_name] strips it again when
it answers one. So [(vec-new t)] worked and had worked since generics landed —
generics.flan's [one-of] and [bump] are written that way — and [(vec-new $t)]
fell past the guard into the no-element-type message, which then described a
missing annotation for a body that had written one.
Three membership tests, one helper: [tyvar_bare] and [tyvar_in_scope] near
[resolve_name], used by [type_named] (which fronts vec-new and map-new) and by
the cast arm. [resolve_name] uses [tyvar_bare] for its own strip, so there is
one place that knows what the character means. A sigil on a name nothing binds
now reaches [resolve_name] too, so [(vec-new $u)] says the variable has no
binding site rather than blaming the element type.
Already fine, both spellings: [(array n $t)], [(zeroed)], [(Some x)],
[(Option $t)], [(Ptr $t)], a [(Vec $t)] return, a [(Map $t i32)] parameter —
every type *position* goes through [resolve], which has always stripped. A
local declared [(Vec $t)] is not a thing in the language: parse gives a let
binding no type slot.
Broken and fixed: [(vec-new $t)], [(vec-new $t a)], [(map-new $k $v)],
[(map-new $k $v a)], [($t x)].
Size and alignment come from the copy: the i32 instantiation of [sorted] emits
flan_vec_init with 4/4 and the f64 one with 8/8, and flan_dev_reg_note_vec
with 4 and 8. The abstract pass holds [Var t] and is never emitted — emit.ml
has no layout for a Var and would die if it were.
The dyn question does not arise: a generic is not instantiated at dyn at all
any more, and the refusal says to reach for the dyn side instead. So no copy
of one of these bodies can reach the dyn container, and the branch in vec-new
that picks it is unreachable from here.
test/programs/generic-alloc.flan is the motivating program end to end;
x86 matches LLVM on it.
docs/SPIKE-GENERICS.md already specified this — "Both spellings are accepted
at a use" — so the doc was right and check.ml was the divergence. No doc
change; the tests are what now hold the claim up.
Two diagnostics came with it, because the fix left the same mistake wearing
two faces. [($u x)] was an unknown function where [(vec-new $u)] in the same
body was an unbound variable, so the cast arm took the sigil clause too. And
the unbound-sigil message said "write the concrete type here" in a signature
that introduces one: it names the variables that *are* bound now, read from
[tyvars] abstractly and from [subst] inside an instantiation, so one run does
not answer the same mistake two ways. Where none is in scope — a struct field,
a global — it is still the rule, because there is no answer to give.
Found while widening the cast arm and left alone: a *declared name* may carry
the sigil. [(defn $foo [x i32] i32 ...)] is accepted and [($foo 3)] calls it;
so is [(defstruct $S [a i32])], and [($S 3)] constructs one — though the type
[$S] cannot be written anywhere, so nothing can hold the result but a let.
The character is reserved in every type position and in no name, so the cast
arm declines a name a binding, a struct or a defn already claims rather than
assume it is a type. That is one decline per table a name can be declared in,
and the arm sits above every one of them: [ordinary_call] and, last in
[named_call], [positional_struct]. Refusing the sigil in a declared name
would close it properly; that is a decision about the spelling and not this
lane's to make.
Left: docs/SPIKE-GENERICS.md still lists map-new, zeroed and the casts under
"Mechanical" as remaining work. They landed.
* The randomness surface, 2026-09-21
The author's ruling on names:
#+begin_quote
call them rand-int, rand for [0,1), rand-int-range and rand-float-range. Also
add a rand-bool
#+end_quote
And on types:
#+begin_quote
rand-int is u64, and rand / rand-float-range are f64.
#+end_quote
** What there is now
~rand-seed~ and ~rand-state~ keep their names; they were not in the ruling.
The five are ~(rand-int)~ a u64, ~(rand)~ an f64 in [0, 1), ~(rand-bool)~,
~(rand-int-range lo hi)~ an i64 in [lo, hi), ~(rand-float-range lo hi)~ an f64
in the same. ~rand-u32~, ~rand-f32~, ~rand-i32-range~ and ~rand-f32-range~ are
not names, and each is refused by the one that is, with a call that compiles.
** The generator changed, and this is the flag for it
The state and its LCG step are untouched, so ~rand-seed~ means what it meant.
The *output function* is now PCG-RXS-M-XS 64 rather than PCG-XSH-RR 32: the
old one folded the state down to 32 bits, and no honest u64 or 53-bit f64 can
be built from 32 bits without a second step. The new one answers 64 bits from
the same single step, so **every one of the five still costs exactly one
draw** — the property at lib/prelude.ml's "More of the RNG" banner survives
unchanged, and a seeded run is reproducible as before.
The cost, written down where it lives — lib/prelude.ml's banner, docs/BUILT.md
and the web page's table, which is where someone choosing a function will meet
it: the permutation is a bijection of the state, so someone holding one result
can run it back and predict the rest. That is the price of 64 output bits from
64 state bits. Fine for a grid or a spawn point, not for a key.
One thing the old wording of this entry got wrong and is worth keeping
straight: "every call is one draw" is true of every call that answers a
number, and a range with nothing in it answers lo without drawing at all. The
prelude, the docs and programs/rand.flan all say it that way now.
The sequence a seeded program gets is therefore different from the old one.
Anything that pinned a hash re-pins it once.
** Left for the author
- sand.flan calls ~rand-f32~ at lines 81 and 108, and names it in the comment
at 128. It is the one file this lane was told not to touch. Until it is
brought up to date, the cases that compile or re-evaluate it skip themselves
— the *fixture* decides, so there is nothing to unpark: fix the two calls
and every case runs again. The guards match ~(rand-f32~ and not the bare
name, because the comment at 128 names it too and a guard that matched that
would never release. The exception is the sand hash in
test/test_acceptance.ml, which is the old generator's grid and has to be
re-taken from a run whatever happens.
- The sand hash ~15595743031174623232~ is the old generator's grid. It is an
assertion in one place only — test/test_acceptance.ml, the ~sand_out~ line —
but it is quoted as prose in six more, and all of them go stale with it:
NEXT.md lines 937, 1277 and 1482, docs/BUILT.md line 4188,
docs/handoffs/HANDOFF-x86-macro-visibility.md line 133, and web/index.html
lines 1916 and 1919. Whoever re-takes the number has that list.
- Not this lane's, noticed while rebasing onto dev-loop: the two
programs/generic-alloc.flan rows in test/test_acceptance.ml fail on the
dev-loop tip as they do here. The file calls ~as-slice~ nine times and the
checker now refuses the name in favour of ~slice~, which is the slice lane's
own refusal answering its own corpus. Untouched here.
- test/test_sanitize.ml and test/test_valgrind.ml name sand-headless too, and
they are not guarded — they are opt-in aliases rather than part of ~dune
test~, so they will report it on the next @sanitize sweep and stop once
sand.flan is fixed.
- Noticed and not fixed: the reader reads a decimal integer literal as a
signed 64-bit number, so a u64 constant above 2^63 cannot be written in
decimal. Hex is read as a bit pattern and works, which is what the
permutation's multiplier uses. The same limit has a second face: a cast's
argument is checked against the default type, so ~(u64 0xAEF17502108EF2D9)~
and even ~(u64 2935910691)~ are refused for not fitting in an i32, while the
literal written straight into a u64 operand is fine. That is why the
multiplier sits in the multiply rather than in a let.
* Chained comparisons, 2026-09-21
From the game, at sand.flan:45:7:
: < takes 2 arguments, given 3
and the ruling:
#+begin_quote
dispatch an agent that's going to do a pass through the stdlib and make
functions variadic, lisp style. We should have +, *, -, and < > et all should
be variadic
#+end_quote
Most of that was already true. [+ - * /], [bit-and], [bit-or], [bit-xor],
[min] and [max] have taken two operands or more since [fold_left_prim] went
in; the six comparisons had not, and they are the ones the game hit. So the
change is one arm: [= != < <= > >=] take two operands or more, [= < <= > >=]
chaining and [!=] asking about every pair — the ruling below.
(< a b c) asks whether a is below b and b is below c. The left fold the
folding operators use would compare a bool against a number, so there was
never a second reading to choose between.
The middle operand is the whole of the difficulty. Two links name it, and the
spelling anyone would reach for by hand — (and (< a b) (< b c)) — evaluates it
twice, which is wrong the moment it is a call. So the chain binds every
operand to a slot first, in source order, and compares the slots. The links
then stop early with nothing observable riding on it: by the time any link
runs, every operand has already been evaluated.
The refusals stayed refusals. (< x) would have to be true whatever it was
handed, which is a typo that carries a value, so it is refused beside (+) and
(- x) — see the note above [fold_arity] for why those two are refused, which
this follows rather than reopens. Lisp answers true there; this language does
not, and the message says what to write instead.
[%] and the shifts are still two operands, for the reasons already written
against them: a chain of remainders has no agreed reading, and (<< x 30 30)
would be two legal shifts that between them shift the value away.
There is no three-way join, and none was invented. The first pair joins the
way any pair of operands joins — the widening rule of 2026-09-20 — and every
operand after it is checked against the type that join produced, so a third
operand that does not fit is refused the way a second one would be. That is
[fold_left_prim]'s rule exactly, and the comparisons now answer the question
the same way the arithmetic does rather than a second way of their own.
!= was built as a chain first and the author ruled against it:
#+begin_quote
adjacent chaining makes little sense to me, (!= 1 2 1) should be false
#+end_quote
So != is all-distinct, Common Lisp's /=: (!= a b c) is true when every operand
differs from every other. The other five keep adjacent chaining.
The two differ because they are different questions. Asking whether a sequence
is increasing is a question about neighbours — c has nothing to say about a,
and (< a b c) is done when it has looked at two pairs. Asking whether a set of
values are all different is a question about the set, and the pair chaining
would never look at, first against last, is exactly the one (!= 1 2 1) turns
on. Only the first question is a chain.
The cost of that is pairwise: n operands means n(n-1)/2 comparisons rather
than n-1. Fine at the sizes anyone writes — four operands is six compares of
values already sitting in slots — and invisible to every program there is
today, because at two operands the two readings are one pair and the
two-operand form does not go through the n-ary lowering at all. It emits what
it always did.
Single evaluation is unchanged by the switch, and is the reason the pairwise
reading costs nothing worse than compares: every operand is in its slot before
any pair is looked at, so the extra pairs read slots. The conjunction still
stops at the first pair that fails, having already run everything.
The slots are slots of whatever type the operands have, which strings are what
says: = and != admit them and the orderings do not, so (= "a" "a" "a") is the
one row here that is not about machine words. A dyn ordering carries the trap
site every pair of it, because the whole comparison is written at one place
and a trap from any of its pairs happened there.
test/programs/chain.flan, on both backends and at -O0 and -O2. What it asserts
that a checker test cannot is the tag transcripts: a chain whose first link is
already false still prints abc, a != whose *first* pair already says no still
prints abcd, and the call counts (10, 12, 15) are what an operand evaluated
once per pair that names it would break — the 12 would be 24.
* Evaluating a def assigns, 2026-09-21
** The gap, hit dogfooding again
The author edited
: (def colors [4 u32] [0xE6B800FF 0xFF0000FF 0xA83232FF 0xCC6B1FFF])
in his running game, pressed C-c C-c, and the colours did not change. That is
the same complaint the entry "def, and defvar renamed to defonce" above was
written to answer, one step further in: the form was right this time, and the
delivery stopped short.
** Why it stopped short: the reading was wrong
[Session]'s [def_inits] said it outright — "a re-evaluated def is a promise
about the *next re-run*" — and did exactly that: republish the lifted
[global/<n>] through its cell, and stop. Nothing called it.
That reading is wrong, and it is wrong for the reason the form is named
after. ~def~ is Common Lisp's ~defparameter~, and **evaluating a defparameter
assigns**. That is precisely what distinguishes it from ~defvar~~defonce~
here. The difference between the two is not "one takes effect at restart";
it is "one takes effect, the other does not touch an existing binding at
all". A promise about the next re-run is a promise ~defparameter~ does not
make and does not need: it assigns now, and it also re-initialises on the
next load. Both, not one or the other.
** What is built
A re-evaluated ~def~ now does both:
- the storage takes the new value at the next frame boundary; and
- the lifted [global/<n>] is republished, so the edited initialiser is the
one the next re-run runs. Unchanged, and not regressed — dev-rerun.flan
still pins it.
The store rides the thunk a redefinition module already carries. A module has
one optional [flan_reload_call], run by the agent after [flan_reload_install]
has published the bodies, at a frame boundary, on the game thread — the
mechanism C-x C-e uses. [Session.eval] puts one ~(Set (Pglobal n) ginit)~ per
re-evaluated def into it, which is the same store [Emit.startup_plan] writes
for the same global, without the [.init~once.] flag a defonce's carries. The
class registrations that already used the thunk share it, and come first.
** The questions, answered
*** When
At the next frame boundary of a running program. A *parked* program drains
its ring when that sleep ends, so the store lands at the top of its next run,
ahead of main — and the run's own startup then computes the initialiser
again. Two runs of the initialiser, and that is right rather than a wart:
they are the two events ~defparameter~ has, an evaluation that assigns and a
load that re-initialises. A program that has not called ~(agent/start ...)~
yet installs at its next ~(agent/poll)~ and never if it has none, which the
reply already says. Stopped at a break, it runs in the break loop like any
other evaluation.
*** An initialiser that signals
The condition goes unhandled, the program stops, and the boundary restart the
agent establishes around every thunk is offered as ~abandon-evaluation~;
taking it unwinds past the store and the program carries on. The session is
not wedged — the next evaluation of the same name stores like any other. The
editor learns of it as a stop: the reply to the evaluation went out at
"queued", which is how every other condition in a running program arrives.
The old value survives for a *scalar* on both backends, and for an aggregate
on LLVM only. A scalar comes back in a register and is stored after the
transfer guard, so the signal jumps past the store; LLVM does the same for an
aggregate, calling into a temporary and storing once. The x86 backend has no
[Set] arm of its own — it lowers a value straight into its place, and a call
returning an aggregate is handed the destination as its sret pointer — so
: (def colors [4 u32] [9 9 (wreck) 9])
writes into the live global element by element: [colors] reads 9 in the first
element with the tail still old at the break, and all four elements zero after
the abandon. Measured on both backends, 2026-09-21.
Pre-existing, and not about ~def~: a bare C-x C-e of ~(set colors (wreck))~
does the same on x86, and the restart's own note already says "anything the
expression changed before it stopped is still changed". What is new is that
every def edit now travels that path, so it is written down rather than
promised away. Routing an x86 aggregate ~(Set (Pglobal …) (Call …))~ through a
temporary — and chasing why the abandon leaves zeroes rather than the partial
write — is a lane of its own and is not this one.
*** A retype
[Session.compatible] fires before anything is built, and its sentence already
reads for this path: "speed changes type, from i64 to string; the running
program already laid that storage out. Restart to change it."
*** A def the process has never seen
Gets its initialiser run too, where before it got [Emit.initial_image]'s
answer for a literal and calloc's zeroes for a computed one. This needed one
thing in each backend: a lifted [global/<n>] asked for by name is neither a
sibling nor one of [lifted] — its [fparent] is the global, not a function in
[fns] — so it got no cell at all, and a call in a dev module goes through a
cell. Both backends now give an unknown one a slot of the module's own,
filled from the registry by the installer, exactly as a defn the host lacks
already was.
*** defonce
Unchanged, and pinned: [kind = Once] never reaches the store list, so
re-evaluating one whose name the program already has builds no module and
answers "nothing to install".
*** defconst
Unchanged, and it was already the immediate one — an unfolded constant is
republished by value at the frame boundary through [Session]'s [consts], the
same store by a shorter road since there is no initialiser to run. A folded
one is refused. Which means ~def~ had been the *worse* of the two on
immediacy, which is not a defensible place for the form that exists to follow
the source.
** Tests
programs/dev-defstore.flan and its block in test_dev.ml: a native array (the
author's case, to the letter), a scalar, a struct, a dyn, a computed
initialiser, a brand-new name with a call and one with a literal, a defonce
control, an uninit def that stores nothing, a defconst re-evaluated live, a
defclass and a def constructing it sent as *one form* — which is what pins
the registrations-before-stores order inside the thunk — a retype refusal,
and an initialiser that signals, abandoned, with the same name edited again
afterwards. All read back out of a *running* program with no re-run anywhere.
Both backends: the default x86 and --llvm.
Two things the fixture spells the way it does for a reason. The defconst is
an f64: the checker folds *integer* constants on the way in, and a folded one
is refused by name rather than published, so an i64 there would have pinned
the refusal instead of the store. And the class the def constructs is paired
with a *new* def rather than one the fixture declares — see below.
** Left alone
- [Session.eval] takes no liveness argument, so it cannot skip the store for a
parked program. Deliberate, per the two-events reading above.
- A full-file C-c C-k now re-runs every def initialiser in the file. That is
what loading a file of defparameters means, and the state "edit the code,
keep the sand" is about lives in defonces.
- A class that gains or loses slots is checked against every caller of its
constructor in the running program, and the lifted [global/<n>] of a def
whose initialiser constructs one *is* such a caller — so redefining a class
and re-evaluating an existing def that constructs it, in one form, is
refused naming ~global/origin~, a name the source never writes. Pre-existing
(the lifted function has been there since def landed) and against the house
rule for diagnostics, but it is [session.ml]'s stale-caller tripwire, which
is documented as unreachable and is not this lane's to loosen. A brand-new
def is unaffected, which is what the test uses.
* The heavy sweep, 2026-09-21 — and the leak question bytes-copy cannot answer
@x86, @sanitize and @valgrind, batched over the four lanes since the last
sweep, all three green at the end. What they found, and what they could not:
One x86 failure, two rows of it: bytes-view-write and dev-segv, both from the
bytes/bytes-view lane. Not a miscompile — both store through a bytes-view of a
string literal, which is .rodata, and the survey compares at its own -O2 where
LLVM deletes the undefined store and exits 0 while the hand-written backend
executes it and takes 139. At -O0 the two agree exactly, and test_acceptance's
dies_segv rows already pin that on both backends. Excluded by name in
survey.sh, with the reason.
One real bug, pre-existing and found by hand rather than by an alias:
--dev --sanitize did not compile at all. See the commit; the short of it is
that clang 20's ASan module pass segfaults on an llvm.global_ctors naming a
declaration, so the weak __asan_init yield that keeps the dev build's SIGSEGV
handler out of ASan's way had never once run.
** What neither corpus can answer about the new (bytes s)
bytes-copy.flan is in both sweeps now, and it is worth writing down what that
does not buy. Its first two cases leak 24 bytes through flan_bytes_dup —
measured, with valgrind --leak-check=full — and both sweeps miss it on
purpose: @sanitize runs with detect_leaks=0 and @valgrind with
--leak-check=no, each for the reason its own file gives, which is that
allocate-once-never-free is this runtime's design and a leak check produces a
suppression list rather than information.
So the row proves the copy is in bounds and its bytes are written. It does not
prove anything about who frees it, and nobody should read a green sweep as
saying the new allocating bytes has an owner. The leak question is worth
asking on purpose one day, across the whole corpus and not one program, and
that is a session of its own.
** Certified against 190fdad
The four-green result is measured against that base. dev-loop has moved since
— runtime/flan_rt.c, vendor/agent/flan_agent.c and lib/dev.ml among others —
and those belong to the next batch, not to this one.
* Diagnostics reworded, 2026-09-21
The author, on a message that ran three lines to explain a naming decision:
#+begin_quote
go through all compiler messages and rewrite them plainly to state what they
mean, I don't need this verbosity, it's too much
#+end_quote
And, correcting the example he gave for it:
#+begin_quote
it should say that defvar doesn't exist. You shouldn't write compiler errors
that report design decisions we've made, but should report errors to use[rs]
who have never used this language and have no idea that defvar even existed
#+end_quote
So the standard is two rules, not one. A message says what is wrong and what
to write, and stops. And it says it to someone holding this compiler and
nothing else: no prior spelling, no milestone number, no plan.org, no
rename framed as a rename. defunion's refusal now states what defunion is and
what to write for a tagged sum, rather than announcing that the tagged sum
"is defdata now".
The headline case is the one the author quoted. It said defvar was renamed
and then explained the choice of name; it now says:
there is no defvar.
Did you mean defonce? (defonce gravity float 0.1) initialises once and
keeps its value. (def gravity float 0.1) re-initialises on every re-run.
Both spellings in it compile as written, which is the standing rule for a
suggestion and was checked by building them.
About 130 messages rewritten across lib/check.ml, lib/parse.ml,
lib/session.ml, lib/load.ml, lib/shim.ml, lib/macro.ml, lib/expand.ml,
lib/cimport.ml, lib/dev.ml, lib/render.ml, lib/build.ml, lib/emit.ml,
lib/x86.ml, runtime/flan_rt.c and vendor/agent/flan_agent.c. lib/reader.ml
was already right and was not touched, nor were parse.ml's "X is (X ...)"
usage lines, which are the shape everything else was moved towards.
emit.ml's thirty assertions are not diagnostics — each one says the checker
admitted something it refuses, so no program text reaches one. They now go
through [Emit.internal], which prefixes "internal:" and says the message is a
compiler bug, so the one person who ever sees one is told what it is instead
of reading "no layout for t" as a statement about their own code. x86.ml's
[unsupported] strings stay as they are: they name the missing feature and
session.ml already wraps them in the sentence with the fix in it.
Review follow-ups. One rewrite had turned descriptive prose into an
imperative that does not compile: the Map-into-dyn refusal said "Write
(map-new dyn) for a dyn map", and there is no such call — map-new wants a key
and a value, and dyn is refused as a key. A dyn map is the map literal, so
that is what it names now. Two more of the same class: shim.ml offered
(as-slice v), a spelling this branch retired in favour of (slice v), and the
defvar refusal echoed the old form's arguments back inside the new spelling
even when there were too few to make a valid one — (defvar x) was answered
with (defonce x), which does not compile. Fewer than two arguments now gets
the shapes rather than an echo.
Trimming went one word too far in one place: the defer refusal ended "or in a
let that is", whose antecedent had been inside the parenthetical that was
cut. And view_not_yet was missed by the sweep entirely — it still carried
five lines about what the collector does and does not scan.
Test needles followed the wording, each one picked to stay specific to the
message it is about. Two rows had to be re-pinned after review: both asserted
"uninit on one is refused", which matches the container-global arm and the
data-type arm alike, so each now names something only its own arm says. One
test was asserting the wrong thing: "a dyn in a
condition's payload" reached the struct-field refusal that fired first, never
the condition arm it was named for. The struct refusal is gone since the
descriptors landed, so the row is an [accepts] now and a new [rejects_check]
signals a dyn directly to reach the arm that is still there.
* len is a variable name now, 2026-09-21
The author, on why:
#+begin_quote
I think I prefer length over len, because then I'll use len as the variable
name
#+end_quote
So the count is ~length~, and ~len~ is left to programs. One arm in
~lib/check.ml~ and one row in the table beside it; everything that wrote
~(len x)~ across lib, test, examples, vendor, spike, docs, web, emacs,
plan.org and NEXT.md writes ~(length x)~ now.
** What this adds to shadowing, which landed beside it
Shadowing and ~builtin/~ had already taken most of the sting out. A
~(defn len ...)~ was legal, it won at every call site in its file, and
~builtin/len~ reached past it. What was still true is that ~len~ was a
builtin: the defn earned a warning, and anything that wanted to wrap it had
to say ~builtin/~. Now there is no builtin under the name at all — nothing
warns, the qualifier is not needed to reach past anything, and the name is
free in every position, which was never the question for a binding and was
the question for a call.
So the two features do different work and the rename does not repeal any of
the other. ~length~ simply becomes shadowing's worked example in place of
~len~: ~shadow-builtin.flan~, ~builtin-qualified.flan~, the package under
~pkgs/shadowed~ and the ~builtin/~ rows in ~test_flan.ml~ all moved to it, and
they go on testing shadowing rather than quietly becoming tests of a free
name.
** The refusal
A call to a ~len~ that nothing in the program defines is answered where an
unknown function is answered — after every table and after the shadowing
guard, so a program with its own ~len~ never sees it:
#+begin_example
test/programs/len-gone.flan:15:3: there is no len. The number of elements in an
array, a slice, a string, a Vec or a Map is length. Write (length a)
15 | (len a))
| ^^^^^^^
#+end_example
Said rather than guessed at: ~len~ and ~length~ are three edits apart and the
did-you-mean's net is one. The reader's own argument is written back out
through ~spell_arg~, which is ~as-slice~'s spelling lifted out of it and now
shared — a name is its name, an integer its digits, anything with structure
inside it becomes a stand-in.
*Only at one argument.* ~length~ takes exactly one, so ~(len xs 1)~ written
back out as ~(length xs 1)~ would be refused a second time the moment it was
pasted, and a suggestion that does not compile is the whole thing this
spelling exists to prevent. At any other arity the shape ~(length v)~ is
suggested instead. ~as-slice~ can write every argument out because ~slice~
takes one, two or three; the difference is the arity and not the style. Two
drafts of this lane got it wrong in turn — the first quoted the source line
and could print an unbalanced form, the second spelled every argument — and
the arity cases are pinned now, which neither draft was.
~(builtin/len xs)~ is the one rough edge left. It reaches ~not_a_builtin~ and
reads "len is not a builtin, so builtin/len reaches nothing" with no
did-you-mean, for the same three-edit reason. Left as it is rather than
special-cased.
** Certified against b8856be
Rebased onto the diagnostics rewrite, which is where the refusal above has to
read against its neighbours — it takes ~as-slice~'s shape, because that is the
refusal beside it and the two answer the same kind of question. dev-loop moves
hourly; this is the base the green result below was measured on.
** sand.flan, for the author
Two calls are left in the tree, both in the file this lane did not touch
because it is the author's:
: 31 | length (len coll)]
: 55 | (set current-color (% (+ current-color 1) (len colors))))
Line 31 is the one to read twice: the binding is ~length~ and the call is
~len~, and a sequential ~let~ makes ~length (length coll)~ legal — the
initialiser is checked before the name it binds exists.
Until both say ~length~, ~test_acceptance~ and ~test_session~ abort on the
first of them — a fatal exception, not a failing row, which is the
hidden-failure shape a previous lane found. sand.flan also feeds ~@x86~,
~@js~, ~@sanitize~ and ~@valgrind~ through their workspace-file deps, so those
go red too. Everything underneath was verified green against a copy of the
file with those two lines changed.
* An accepted re-run is running, 2026-09-21
Reported as an intermittent failure of test_dev's re-run loops under load:
"after three re-runs the program never printed \"counter 44\"", with the
transcript stopping at counter 43 — three requests, two runs — and, less
often, the second request refused outright with "rerun: the program is already
running".
Two faces, one cause, and the cause is a state that lagged a decision.
[flan_merged_rerun] took the request under the lock and left [program_state]
as it found it: PROGRAM_PARKED. The flip to PROGRAM_RUNNING happened on the
parked thread, when it got round to waking — and [describe]'s [:parked] reads
that same state. So a caller that asks for a re-run and then waits for the
program to park again was liable to be answered by the park it had just ended.
The wait fell through immediately, the next request went out while the first
run had not started, and one of two things happened: [program_asked] was still
1, so both requests were answered ok and a single run came of them; or the
thread had woken in between, and the second was refused as "already running".
test/test_dev.ml does exactly this shape in four places, three of them in a
loop, which is why it was the tests that found it.
The window was documented. The comment over [flan_merged_park] said two
re-runs arriving in it are both answered ok for one run, called it a
microsecond wide before the state flip moved in front of the flush and "as
wide as a flush" after, and traded it against a refusal that was simply false.
What it did not account for is the drain that later went in front of the park's
exit: the leaving round polls the agent's ring — a dlopen and a module install,
or a thunk that stops in the break loop — before it breaks. The window was no
longer a flush. It was however long the next thing the program had to do took.
Fixed by making the state the decision rather than a report of it. The store
is in [flan_merged_rerun], under the lock that accepted the request: accepted
means running, and there is no moment in which a committed re-run reads as
parked. The park's own store on the way back into main stays, now a no-op, as
the one place that knows the thread really is on its way.
Considered and rejected: teaching the tests to wait on something else — a line
of the new run's output, a counter — rather than on the park. That is the
right fix only if the daemon's contract really is "parked until the thread
wakes", and it is not: the C refuses a second re-run precisely because the
first is committed, so the two answers disagreed about the same fact. The
contract wanted stating, not working around.
Also considered: keeping the state and refusing a second request while
[program_asked] is set. It closes the collapse and leaves the refusal — the
wait for a park is still answered by the stale one, and the next request is
still refused. Only the state carries both.
Nothing depends on PARKED lasting until the wakeup. [Program.state] has three
readers: [Dev.liveness], which is every op's guard; [describe]'s [:parked];
and [flan_merged_wake], which declines a running program because a running
program polls its own ring. The last is the only one whose answer changes in
the window, and the decline is right there too — the round the thread leaves
on drains the ring before main is re-entered, and what misses that drain is
picked up at the new run's first frame boundary, exactly as for any running
program. No test asserts [:parked t] after an accepted re-run; the one that
asserts it against a thunk stopped in the park asks before any re-run.
There is no path where the request is taken and the run does not happen. The
only way out of the park's loop is the [program_asked] test, and the thread is
either in the wait or in the agent poll that wait runs. What can delay it is a
thunk that stopped in the break loop, which holds the thread inside the poll
until somebody resumes it. The run is still committed — that is why [rerun]
accepts it and says so in a note — and for the duration the state says running
while no frame is executing. Deliberate: [:stopped] is what reports a break
loop, [:parked] reports a park, and this is neither. [Dev.rerun] reads the
liveness and the break for that note *before* it calls [Program.rerun], since
after the call the answer is running by construction.
The two-process daemon has no such window. Its program is a child process with
no park at all: [flan_program_rerun] finds the weak symbol null and answers
"this session's program is a process of its own", and liveness comes from
[waitpid] — a child that finishes is Gone, not parked.
Measured rather than argued, on the loop at test/test_dev.ml:5817 driven
standalone against programs/dev-rerun.flan — park, three re-runs each waited
for by [describe], then the transcript. Unloaded: 3 failures in 28 runs, both
faces among them. Under two busy-loop burners, this machine's load average
running 8 to 16: 9 failures in 25 runs. After the fix, same harness and the
same two burners at the same load: 0 in 50.
Related but separate, and not this: test_reload's "the registry read under a
writer" is a reader racing a writer over the allocation registry, about one run
in five in isolation, and shares nothing with this but the word intermittent.