2239 lines
131 KiB
Org Mode
2239 lines
131 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.
|
||
- *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.
|
||
|
||
* ~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 wins and a note
|
||
surfaces at the error the shadowing caused. 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.
|