- SA_NODEFER. sigaction without it 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: the kernel forces the default action. Fault, park, eval something at the break loop that faults, daemon gone, exactly the author's session one level in. Measured both ways; flan_crash_entered is cleared before the hook so each break-loop fault still gets its line, and the case is pinned (trap_park ~refault:true), confirmed to fail without the flag. - Scope the handler to the thread it was armed on. A disposition is per process and a merged dev session is one process, so this was shadowing OCaml's SIGSEGV handler — and Stack_overflow — for the daemon's whole life. Other threads chain to what was installed before. Arming per run would leave the parked prompt's evaluations unprotected, since those are program code too; the comment says so. Also makes the per-thread sigaltstack honest. - Sweep dyn-view.flan and string-eq.flan, which dev-loop added after the first sweep. string-eq:46 wanted the aliasing outright: its comment is about two slices sharing a base pointer. - A StorageExhausted row for bytes, asserting the retry copies once and whole rather than re-evaluating its argument. - Gate the flan_dev_crash_enable declare to dev builds, so this lane adds no dev-only text to a release module. flan_bytes_dup stays ungated: a release build really calls it. - Guard the section for wasm32, which compiles this file and has no signals.
4184 lines
243 KiB
Org Mode
4184 lines
243 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.
|