5821 lines
332 KiB
Org Mode
5821 lines
332 KiB
Org Mode
* 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 Flan’s 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 161–166), 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.
|
||
|
||
* 2026-09-21 — [--dev --sanitize] was unbuildable, and nothing built it
|
||
[--dev --sanitize] could not compile a single program. Any program, any
|
||
optimisation level: clang 20's AddressSanitizer module pass segfaults on a
|
||
module whose [llvm.global_ctors] names a function the module only declares,
|
||
and both of ours were declarations — they are C in the runtime. It had been
|
||
that way for as long as the allocation registry has been armed from a
|
||
constructor. The fix is f75b0ca on the crash-handler lane, merged into
|
||
dev-loop as af71459: the table names a local [Mangle.dev_ctor] definition
|
||
that calls the two, which is the shape clang emits for its own constructors.
|
||
This lane hit the same wall independently on a base that still had the
|
||
one-entry table, wrote the same fix restricted to it, and dropped that commit
|
||
on the rebase in favour of dev-loop's.
|
||
|
||
Nobody knew because no alias built the combination. [test_sanitize.ml] built
|
||
the corpus twice and neither time with [--dev]; [test_dev.ml] builds [--dev]
|
||
and never with a sanitizer. A configuration nothing builds can be broken for
|
||
a month, and this one was — and the fix could have rotted the same way.
|
||
|
||
** What the new check is
|
||
[dev_sweep] in test/test_sanitize.ml, under the existing @sanitize alias —
|
||
no new alias to remember, and nothing added to [dune test]. Seven programs
|
||
built [--dev] twice, plain and sanitized, and run standalone with no daemon:
|
||
dev-noagent.flan (the only one of the sixteen dev-*.flan that does not import
|
||
the agent, and so the only one that does not stop waiting for an editor),
|
||
println, values, text, and the three dyn programs. A sanitizer report fails,
|
||
and so does any divergence from the unsanitized dev run.
|
||
|
||
Standalone is what makes it nearly free, and it works because a dev build is
|
||
not a dev *session*: the cells, the marker and the constructor are in the
|
||
executable either way, and a program that never calls (agent/start ...) runs
|
||
to its own end.
|
||
|
||
The three dyn programs are not interchangeable and the difference is the
|
||
collector. [dyn-vec] allocates objects of three kinds and never collects —
|
||
flan_dyn.c has a one-megabyte floor and dyn-vec does not reach it — so what
|
||
it says is that the dev registry and the allocator agree. [dyn-map] and
|
||
spike/x86/p13-dyn-collect.flan are the two programs anywhere that allocate
|
||
past that floor, so they are the only ones under which a mark and a sweep
|
||
actually run; until they were in this list, a collection had never happened
|
||
in a dev build under ASan at all. An earlier draft of this entry claimed
|
||
dyn-vec covered that, and it did not.
|
||
|
||
[dev_segv] is beside the sweep rather than in it, because the one dev program
|
||
that faults cannot be compared against an unsanitized run: the dev build's
|
||
SIGSEGV handler prints its line and parks in the break loop, so the plain
|
||
half of the pair would hang, and the two are supposed to differ. ASan is
|
||
meant to own the fault — [flan_dev_crash_enable] checks a weak [__asan_init]
|
||
and declines to install the handler when ASan is in the process — so the case
|
||
asserts ASan's report and the absence of the handler's line. Built at -O0,
|
||
because at the sweep's -O2 the write through a bytes-view of a literal does
|
||
not fault at all; both builds print the unmodified string. This is the line
|
||
the emitter fix unblocked: until the constructor stopped naming a
|
||
declaration, no [--dev --sanitize] build linked, so the yield had never run
|
||
anywhere.
|
||
|
||
Verified both directions rather than asserted: reverting [Emit] to a table
|
||
naming the two declarations gives eight failures — the seven dev programs
|
||
and dev-segv, each of them the clang crash — and a nonzero alias exit, and
|
||
the fixed emitter is clean. Twenty-six seconds of the whole alias's 2m30 on
|
||
this machine, measured warm; BUILT.md's nine minutes is the cold figure. The
|
||
static link is the expensive half and the object cache already holds it by
|
||
the time this part runs.
|
||
|
||
** Two aliases were green only because [dune test] runs first
|
||
@sanitize's deps never listed the package directories pkg-diamond.flan
|
||
imports, and @page's never listed sand.flan, which quotes.sh reaches through
|
||
test/programs/sand-headless.flan. Both only ever passed because [dune test]
|
||
had already materialised those files in _build; from a genuinely cold tree
|
||
@sanitize died on pkg-diamond before its first sanitized build. @page sits
|
||
inside @checks, where CI hides the same thing. Both fixed here. @valgrind,
|
||
@x86, @js and @cells were checked and are complete.
|
||
|
||
** What it still does not check
|
||
- A program driven by a real [flan dev] daemon under ASan. The daemon builds
|
||
its host through [Dev]'s own path and the CLI has no [--sanitize] to pass
|
||
it ([flan dev <program> [-s socket] [--x86]]), so this wants a flag and a
|
||
way through [Dev.serve]. Not built. It is the check worth adding next, and
|
||
it is a day rather than an hour.
|
||
- The x86 backend. [--x86 --sanitize] is refused by [Build] by name — there
|
||
is no sanitizer pass in the hand-written backend — so the configuration is
|
||
LLVM-only and there is nothing to track.
|
||
- The reload path, which was already outside @sanitize: a redefinition module
|
||
is built by llc and ld, so nothing instruments it.
|
||
|
||
* 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.
|
||
|
||
One test is strengthened rather than served by this. test_dev.ml:763 asserts
|
||
that a re-run asked for while the program is running is refused as "already
|
||
running" — and that assertion was itself racy: it fires after the run it
|
||
re-ran has been waited for, so the run could have finished and parked, and the
|
||
refusal it demands would not have come. With the flip at the acceptance the
|
||
RUNNING state starts earlier and covers more of that gap. It is a narrower
|
||
window, not a closed one; if that row ever flakes, the answer is to make the
|
||
test ask while a run is demonstrably in progress, not to widen the state
|
||
further.
|
||
|
||
Three sentences went stale the moment the state moved, and were rewritten with
|
||
it — the same class of defect as the one being fixed, since each was correct
|
||
only while a committed re-run still read as parked. [flan_merged_park]'s note
|
||
that the program stays PARKED across a poll, now true of every round but the
|
||
one it leaves on. [Program.rerun]'s refusal, which told a reader to close a
|
||
window or let the program finish when what holds it is a break loop: it takes
|
||
what the caller already knows and says resume or abort instead. And the
|
||
five-second timeout in [eval_expr] and [run_render_thunk], which reached its
|
||
break-loop sentence through the [Parked] arm and would otherwise have asked
|
||
whether a game loop calls [(agent/poll)] about a program stopped between runs.
|
||
|
||
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.
|