93 Commits

Author SHA1 Message Date
9eb87e486a A backtick was a name character, which is how the apostrophe used to be
`(a b) came back as the unknown name "`" — precisely the failure the
reader's own header warns about for the apostrophe, one sigil over and
still open. Same fix: the sigil reads as a wrapper and the reader stays
dumb about what it means.

Clojure's ` ~ ~@ rather than Common Lisp's ` , ,@ because a comma is
already whitespace here, and every binding vector in the corpus is
written assuming that. Changing is_delim to free up the comma would
rewrite more of the language than macros are worth.

Backtick and tilde join is_delimiter so that a~b is two things and can
never be one name. No symbol in the corpus contains either character, so
closing the class costs nothing now and would cost a migration later.
2026-09-11 20:13:32 +07:00
da38a3db5f An EDN tokenizer, as a package
vendor/edn rather than the prelude: the prelude is prepended to every program
and everything in it is emitted, so a reader nobody imports would be a cost
every build pays.

The tokenizer only. A type-directed reader - the compiler emitting a parser
from a walk over a struct's fields, the dual of the printer C-x C-e already has
- lands in check.ml and emit.ml and is not this. What a caller writes today is
a struct reader by hand against the cursor, and the acceptance program carries
one, because that is what proves the API is usable rather than present.

Every token is a slice into the source, so nothing allocates and the buffer has
to outlive the tokens. That contract is stated at the top of the package,
because it is the kind of thing found the hard way.

Escaped strings are refused rather than half-supported: unescaping needs a copy
and there is nowhere to put one, and handing back the raw bytes would return a
three-byte string as four with a backslash in it. Each other refusal carries its
own sentence - #inst and #uuid separately from tagged literals, because a file
is most likely to contain those two and being told tagged literals are refused
would not say that the timestamp is the thing to delete.

Errors live on the cursor, a code and a byte offset, not in the return type: an
Option loses the position, which is the whole point for an editor. A failed
cursor is poisoned so a caller's loop terminates on a malformed file rather than
spinning.

# Conflicts:
#	test/test_acceptance.ml
2026-09-11 20:06:47 +07:00
ad092d7d02 The fixed stack needs a case, and .5 needs a decision
err-too-deep was the one error code nothing observed. The message is the least
of it: the plausible wrong version is `>` where the guard wants `>=`, which
writes one element past a [32 i32] and traps at exit 134 rather than answering
anything. 33 opening brackets is the input that separates them, and it is the
whole justification for a fixed array instead of a growable stack — the place
this lane pushes hardest against having no allocator.

`.5` reads as a float here and does not in EDN, where a number must start with
a digit and `.` is a legal symbol-start byte. That makes it a reinterpretation
of a token that is already legal as something else, which is exactly what the
house rule says to name rather than leave to be discovered, so it is written
beside the refusals.

Also: every symbol in the table was lowercase, so the A-Z half of alpha? was
unexercised and a version missing it passed. Enemy/Goblin in an existing dump
rather than a new case. And a line under "Internal helpers" saying the heading
is intent and not enforcement — a package has no visibility, so edn/scan-atom
is as callable as edn/next, the same way rl/get-color-raw is.

Both new cases verified by mutation: the depth guard traps, and alpha? without
its uppercase range fails Enemy/Goblin.
2026-09-11 20:04:29 +07:00
7e7f77f2da The struct reader is what proves the cursor is usable
read-enemy in test/programs/edn.flan is the worked example the API is for: the
map opened, the keys looped over, each known one dispatched onto its field and
the rest skipped, written by hand because the compiler cannot emit it yet. It
is there rather than in a doc comment because an API only a compiler could
call would be present without being usable, and writing one out is the only
way to find out which it is. Two things came back from writing it — that
float-of has to accept an integer token, since a config file writing `:speed 2`
for an f32 field is not making a mistake, and that a caller needs `fail` on the
cursor, because a reader's own "expected an integer here" has nowhere else to
get a position from.

The expected output is a raw literal. The dump is brackets and quotes end to
end, and escaping it into an ordinary OCaml string would put a second reader
between the test and what the program printed.

Every case was checked by breaking the tokenizer and watching it go red;
sixteen of them, each restored afterwards. The ones worth naming, because they
are the ones that could have been quietly unobservable: dropping the escape
refusal, accepting `#{`, and collapsing every refusal onto one message — that
last is the shape where a table asserting only "it failed" stays green while
observing nothing. Also: a semicolon no longer ending an atom, a comment scan
that does not test for end of input (which traps rather than differing, on the
comment with no trailing newline), the ratio rule widened to any atom
containing a slash (which takes foo/bar with it), text slices left including
the quote and the colon, a closer counted but not matched, any byte accepted as
a symbol start, a comma not counted as whitespace, and skip-value consuming one
token instead of a whole collection.
2026-09-11 20:01:21 +07:00
d07d6fb4db vendor:edn has to be a build dependency of the tests
An import reads the directory at build time, so a package that dune has not
copied under the test's build dir does not resolve — and the failure is a
missing collection, not a missing file, which reads like a bug in Load.
2026-09-11 19:58:16 +07:00
19be614f22 A tokenizer is what fits without an allocator
The type-directed half — (read-edn Enemy bytes), a parser emitted from a
compile-time walk over a struct — is the compiler's work and is not here.
What a running program can have today is the half underneath it, and the
shape of that half is decided entirely by there being no heap: a token is a
slice of the input, so reading a file costs one buffer and nothing else, and
the cost is a lifetime contract the types cannot state. It is stated in the
header instead, because a dangling [u8] is otherwise found from a corrupted
string several frames later.

A package and not the prelude. The prelude is prepended to every program and
everything in it is emitted, so a reader nobody imports would be a tax on
every build.

Token kinds are i32 constants rather than a defenum, which reads like a
downgrade and is not one: an Enum value cannot be compared with `=` (emit
fails) and a keyword is not a pattern (`match` refuses one), so a defenum here
is FFI-only and a caller could not branch on a kind at all. Both fixes live in
check.ml and emit.ml, which this lane does not touch.

Errors land on the cursor — a code and a byte offset — rather than in an
(Option Token). None says something went wrong; an editor needs to know where,
and a second out-parameter for the position is the same two fields with a
worse shape. A failed cursor is poisoned so a caller's while loop stops
instead of spinning. error-message turns a code into the sentence, and every
refusal gets its own: escapes, sets, tagged literals, #inst and #uuid
separately, metadata, ratios and characters each name themselves and say why,
so a file using one fails with what to remove rather than with a number.

Escapes are the refusal that had to be a refusal. Unescaping needs somewhere
to put the copy and there is nowhere; returning the raw bytes would hand back
a three-byte string as four, with a backslash in it, and nothing would say so.

Balance is checked in `next` against a fixed [32 i32] stack in the cursor,
because `[1 2}` is malformed in a way only the tokenizer has the position for,
and a growable stack is another thing there is no allocator for. Past 32 the
answer is err-too-deep rather than a closer that quietly went unchecked.

Symbol starts are a list and not "anything that is not a delimiter". Without
that, `@` and a backtick read as one-character symbols instead of being
reported; the ratio test is likewise digit-started only, so foo/bar stays a
namespaced symbol.
2026-09-11 19:58:04 +07:00
66cd83d2a1 wasm32 runs the table, and the hash matches
sand-headless prints 2256461126764447066 on native and on wasm32, at -O2 and at
-O0, in one dune test run. That is the whole point of the exercise and the
reason rand-f32 is written in Flan rather than bound to libc.

The old note said the builtins archive has to come from a wasi-sdk release. It
does not: emscripten builds the same compiler-rt and it links correctly under
the other name. It is a different triple built by a different clang, so it is a
substitution rather than the real article, and both the code and the note say so
- nobody should read "wasm32 works" without knowing which joint is glued.

Two findings the note did not have. The entry point is __main_argc_argv, not
main, and the link succeeds before trapping on a signature-mismatched weak stub.
And the target has to reach the C compiles as well as the link, since flan_rt.c
includes stdio.h.

Also corrects why sand is two programs. Two claims had been run together: raylib
does work on wasm through emscripten, and a game loop is expressible there with
emscripten_set_main_loop - a different main, not a different program. What
justifies the split is only that a headless test needs no window on any target.
What makes it mandatory is Load collecting a package's C and link flags whether
or not anything references the package, and that is the thing to fix.
2026-09-11 19:51:33 +07:00
32e20f03da Merge branch 'wasm32' into dev-loop 2026-09-11 19:48:51 +07:00
8a175ebec5 Read wasi-sdk's version instead of guessing it, and pin the one ABI path left
The wasi-sdk candidate had an LLVM version in it, which moves release to
release — so the path advertised as the proper article would have matched only
by coincidence, while the emscripten one beside it was derived. Both are
derived now.

calc-me on wasm32 covers what the other three cases cannot: flan_argv hands
Flan an array of flan_slice built in C, so what it pins is the element stride
of a ptr+len pair — 16 bytes native, 12 on wasm32 — rather than a field
offset. It is also the claim in this file's own header, that the table runs on
the second target, honoured for the first time.

flan emit refuses --target rather than stripping it. The IR really is
target-free, so ignoring it is correct and silence about it is not.
2026-09-11 19:48:04 +07:00
e4586b55c7 The Image family, and 22 shapes
Image first and deliberately: it is CPU-side, so it is the only large piece of
raylib that can be asserted headlessly rather than looked at. gen-image-color,
the pixel reads, both flips, a PNG round trip through export and load, and the
resize and crop dimensions and contents are all in the table at -O2 and -O0.
The shapes, text and timing calls are observed only, by running sand under Xvfb
and looking, and the program and NEXT.md both say which is which.

Five permutations were run red and restored: Image's width against height and
mipmaps against format, GetImageColor's two indices, the two flip wrappers
bound to each other, and the crop rectangle's width against height. The third
of those also broke the export and load lines, which is what makes the PNG
round trip verified rather than merely plausible.

Two corrections to the brief it was given. MeasureText is not headless material
- it measures with the default font, which only InitWindow loads, and a C probe
returns 0 - and the same is true of the frame-time and screen-size calls. And
the raylib.h on this machine is 5.1-dev while the linked library is 5.5, so
every signature was checked against nm -D instead: IsImageValid rather than
IsImageReady, and DrawRectangleRoundedLines takes no thickness.

Font loading is refused by name. A Font carries a Texture2D, a Rectangle* and a
GlyphInfo*, and a GlyphInfo carries an Image - two more aggregates and two owned
arrays, for something with no headless test.
2026-09-11 19:48:03 +07:00
cb11fdf272 Write down what the Image family taught, where the next lane will look
The section on what a headless FFI test can and cannot pin was written before
anything CPU-side was bound, so it had no example of the one shape that beats
store-and-return: scalars in and struct fields out, with nothing for a
permuted layout to cancel against.

It also did not say that MeasureText answers 0 without a window, which is the
assumption this lane started with and had to measure its way out of. Two
lanes have now guessed the same thing.
2026-09-11 19:47:08 +07:00
05676f3181 The two bindings nothing was calling, found by listing rather than by reading
An audit over every public name in raylib.flan against every file that calls
one turned up draw-circle-v and load-texture-from-image with no call site at
all — bound, linked, and never once executed, which is the state the parent
commit already made a rule about. Reading the diff had not caught either.

load-texture-from-image now has the only call site it can have: sand.flan
loads brush.png a second time as an Image, mirrors it in RAM, and uploads
that. The two badges sit side by side, so a flip that did nothing or an
upload that took the unedited buffer shows as two identical sprites rather
than as nothing.

draw-circle-v fills the dot at the world cursor's centre, beside the pixel
that was already there — both Vector2 forms, so both land where the ring's
centre is rather than where an integer cast would have put them.

Still uncalled and not this lane's to invent a use for: key-released? and
mouse-button-pressed?.
2026-09-11 19:46:44 +07:00
8c99c12005 Rounding, sqrt and the rest of the bytes family
f32 only, and each refusal by name: clamp and abs stay compositions of the
min/max builtins, split-at wants a pair type there is no way to spell, and the
f64 and other-element-type copies wait for a program that wants them.

-lm goes on every link, after the objects. The default --as-needed drops a
library named before the object that wants it, and at -O2 LLVM folds most sqrtf
calls into the hardware instruction so nothing has to resolve - which makes the
flag look unnecessary until the -O0 build emits the call and fails to link. That
is how it was found, on the -O0 acceptance run.

sqrt is libm's rather than Newton's, because there is no bit cast between f32
and u32 to seed a guess from, and IEEE-754 makes sqrt correctly rounded so
libm is bit-identical across targets anyway. llvm.sqrt.f32 as a builtin would be
better still - one instruction, no symbol, no link flag - and belongs to
whoever next touches check.ml.

The finding worth keeping is a test that came back green when it should have
been red: nothing in the table could observe floor's zero guard, because
(ceil-f32 0.0) is +0.0 either way. (floor-f32 -0.0) is the only case where it
shows, and the prelude comment had claimed the wrong justification for it.
2026-09-11 19:44:34 +07:00
a90badbcd5 A camera nothing looked through was a camera nothing tested
begin-mode-2d and end-mode-2d have been bound since Camera2D went in and
called by nothing, which is the same as not having bound them. The grid now
draws through a camera the arrow keys pan and comma and period zoom, and
paint has to undo that transform with get-screen-to-world-2d — so a camera
plumbed in wrongly is visible at once as grains landing somewhere other than
the cursor, rather than as nothing at all.

The shapes, the text and the timing come with it, and none of them can be
asserted: every one needs a GL context, and measure-text needs init-window
too — the default font is loaded there and nowhere else, so headless it
answers 0 for every string. Measured against libraylib.so.550, not assumed,
which is why it is absent from the acceptance table despite looking exactly
like a call that belongs in it.

So the HUD is built to be looked at instead: each shape binding appears once
and each is asymmetric enough that crossed arguments show. The ellipse is
wider than it is tall, the ring's sweep comes from get-time, the triangle has
its counter-clockwise winding with an outline over it as a control, and the
panel is sized by measure-text rather than by a guess.

draw-rectangle-rounded-lines takes no thickness in raylib 5.5 — it moved to
the -ex form, and both are here. The 5.1 header on this machine still shows
the five-argument version; nm -D on the library is what settled it.

Font loading stays unbound and says so: a Font carries a Texture2D, a
Rectangle* and a GlyphInfo*, and a GlyphInfo carries an Image.
2026-09-11 19:44:25 +07:00
046593acf1 Say which joint is glued, so the next session does not trust it
Item 6 said the builtins archive has to come from wasi-sdk. It does not have
to, and what is standing in its place is emscripten's compiler-rt for a
different triple — which works, and is worth writing down as a substitution
rather than leaving as "wasm32 works".
2026-09-11 19:43:42 +07:00
b14793517b The hash, asked of the second target and compared to the first
sand-headless imports no raylib so that it can run here, and the point of the
case is not that a module exists — it is that the number matches native byte
for byte, which is only possible because rand-f32 is Flan's rather than libc's.
It does, at -O2 and at -O0; -O0 is the cheap way to say the agreement is not a
coincidence of how LLVM folded the float arithmetic. values and machine run
there too, which is where a 32-bit pointer would have shown.

Four separate things can be missing — clang's wasm target, the sysroot, the
builtins, a WASI runtime — so the skip is a probe rather than a lookup: build
the smallest program and run it, and print what went wrong. A which(1) would go
red on the machine where Node is too old, with a reason nobody could read.

No wasmtime and no wasmer here, so the runner is node:wasi, with wasmtime and
wasmer preferred if either appears. --no-warnings because node:wasi writes to
stderr on every run and this harness compares combined output.
2026-09-11 19:43:35 +07:00
48a7186c58 Asking for the other target, and being told where it cannot go
--target= carries a value, so the flag test becomes a prefix match and the
residual-argument filter uses the same test — otherwise -o out --target=X fell
into the usage error. A wasm build defaults to a .wasm name, since the
extension is what tells a runtime, and a reader, what the file is.

flan run refuses the flag by name. It builds and execs, a cross-built module is
not something this host execs, and choosing a runtime for it is not a decision
this command should be making quietly.
2026-09-11 19:43:35 +07:00
8e074bf0e2 A target is more than a triple, so build.ml learns the rest
wasm32-wasi needs a sysroot clang does not know about and a builtins archive
Fedora does not ship, and both have to reach the C compiles as well as the
link — flan_rt.c includes <stdio.h> and never got past it. The flags are
computed once and the whole list, not just the triple, is in the object cache
key: repointing a sysroot must not be served a stale .o.

Fedora ships no wasm libclang_rt.builtins.a and clang's resource directory is
root-owned, so a shadow one is built under the object cache with the archive
under the name clang looks for. The archive substituted is emscripten's
libcompiler_rt.a, a different triple built by a different clang; wasi-sdk is
the proper article and the comment says so, because a session reading "wasm32
works" should know which joint is glued. Nothing found means a refusal naming
every path tried.

The entry point is the other thing no triple tells you: wasi-libc calls
__main_argc_argv, the .ll says @main, and the mismatch links clean and then
traps on a weak stub. Two lines of C bridge it, and the asm label in them is
why the shim is not an infinite self-call.

--dev and Build.shared are refused for the target rather than half-supported:
both are dlopen, which wasm32 has no equivalent of.
2026-09-11 19:43:23 +07:00
5421408a77 A stopped program, from Emacs
The break loop had no editor half: you drove it with a raw socket. Now the
daemon carries the state, the modeline says the program is stopped and on what,
and C-c C-b is a completing-read over the restarts with abort last on the list.

The lead finding is a bug in the break loop itself, not in this lane.
flan_agent_poll was not re-entrant and has to be: a C-x C-e thunk may itself
error, and the break loop that catches it polls again from inside that call. The
old loop cached head and tail and stored tail back at the end, so the outer call
rewound the index over everything the nested poll had consumed and re-ran the
thunk that had just stopped the program - an unbounded recursion of breaks. A
job is claimed now, tail advanced past it, before it runs. Falsified by
reverting the shape and watching the new test fail.

State reaches the editor two ways because one is not enough. It rides on every
reply, since the likeliest moment to stop is just after an evaluation and a poll
would report it only after the echo area had said the eval was fine; and a
one-second timer, since a program that stops in its own game loop produces no
reply at all. The timer never reconnects, because that would erase the lost
state, and skips while a request is in flight, because accept-process-output
runs timers and a poll firing inside a read would eat that read's reply.

ok from restart means accepted, not resumed, and the note says so - the program
resumes at its next pass of the break loop, which is not this thread's to
promise.
2026-09-11 19:43:18 +07:00
db7be70f7d Rounding from the one mode the language has, and sqrt from libm
floor, ceil and round over f32, which is what a position and a tile coordinate
are here. The only rounding mode available is the cast's truncation toward
zero, so each of these is that cast plus the correction the mode does not
make, and the content is which inputs make the cast itself undefined. NaN
fails every comparison, so it needs its own (not (= x x)) and nothing else
finds it; the infinities fall out of the magnitude test; and above 2^23 an f32
has no fractional bits left, which makes returning the input there the exact
answer and also the guard that keeps the cast inside i32.

round is half away from zero, written as floor of the magnitude and mirrored.
The obvious (floor-f32 (+ x 0.5)) is wrong twice: half-up rather than
half-away, so -2.5 comes out -2, and at the largest f32 below 0.5 the addition
alone rounds to 1.0 and answers 1 for a number under a half. Both are in the
table, which is why every case there is a negative or a half.

sqrt is the decision in this commit and it goes out to libm, which is a change
to the release link and so is said out loud. Every other number in the prelude
is reachable from the four operations and a cast; a square root is not.
Newton's method needs a starting guess, the good guess comes from
reinterpreting the exponent bits, and the language has only value-preserving
casts - no bit-cast between f32 and u32. Without one the iteration needs a
scaling loop to normalise and still produces a result that is merely close,
which is the one thing a standard library must not hand back. IEEE-754 makes
sqrt correctly rounded, so libm's answer is the same bit pattern on native and
on wasm32; for this function the byte-identical argument points at C rather
than away from it.

The cost is -lm on every link, and its placement matters. It goes after the
objects, not in the leading flags, because --as-needed drops a library named
before the object that wants it. Worse, at -O2 LLVM folds most sqrtf calls
into the hardware instruction and the symbol never has to resolve - so this
looked linked before the flag existed and failed only at -O0, which is exactly
why the table runs both. Untested against --target=wasm32: wasi-libc ships
libm.a as a stub because the symbols live in libc, so it should be inert
there, but nothing here exercises it.

The better fix is not in this lane. llvm.sqrt.f32 as a builtin in check.ml and
emit.ml is one instruction, no symbol and no flag, and it belongs to whoever
owns the compiler.
2026-09-11 19:43:07 +07:00
d9ce1baa53 Take the abort, and refuse a name that is two requests
Every check of `abort' so far was of it being refused while the program runs.
The accepted path -- the one that ends a program -- was code that had never
run and answered `ok'. So the break block breaks its program once more, by
installing a `step' that errors into the loop that calls it, and takes the
exit: the daemon owns the program's lifetime, so no `close' is sent and the
daemon coming down on its own is the assertion.

And `restart' refuses a name with a control character in it. The agent's
contract is one line per request; a newline in a name is a second request
smuggled into the first. `completing-read' with require-match cannot produce
one, but the guarantee belongs to the end holding the socket, and an editor
is not the only thing that can speak to it.
2026-09-11 19:42:23 +07:00
4fe2f36d98 Substring search, trim and a parse-f64 that refuses what strtod accepts
Finishing the text family the previous lane started. All three are over [u8]
and none of them allocates, which is what decides their shapes.

trim answers a slice of its input. That is the only shape available without an
allocator, and it is also the better one: there is no new storage, only a
narrower view of the caller's, so the result dies with its owner and trimming
modifies nothing. Both loops test (< lo hi), because an all-whitespace input
otherwise walks lo past hi and (slice s lo hi) traps on a reversed range - the
same trap the bounds table already asserts on. That input is in the case list.

index-of-bytes is naive and stays naive. Boyer-Moore wants a skip table sized
by the needle, which is an array, which is an allocation. The empty needle
answers Some 0 so that index-of-bytes and starts-with? agree on every needle,
and the length test returns before the loop so a needle longer than the
haystack cannot build a window off the end.

parse-f64 splits the work where the two halves actually differ: the grammar is
Flan's and the rounding is libc's. parse-i64 is entirely Flan because strtoll's
answers are wrong for a caller - 0 for "", 0 for "abc", 12 for "12x" - and not
because decimal-to-binary conversion is suspect. Reimplementing correctly
rounded conversion is a different and much larger problem than rejecting junk,
and IEEE-754 already guarantees strtod gives the same bits everywhere. So this
validates the whole slice and only a slice that is entirely a number reaches
bytes->f64. Every refusal in the table - "", "abc", "1x", ".", "1e", " 1",
"1 ", "0x10", "nan" - is a plausible number out of strtod.

Two caveats, both written into the source rather than discovered later. The
locale worry that keeps parse-i64 in Flan does apply to strtod's decimal point,
and is moot only because nothing in the runtime calls setlocale; if that stops
being true this is what breaks. And the length is capped at 511 because
flan_bytes_to_f64 truncates there - a validator that approved 600 digits would
be approving a different number than the one strtod reads.

digit? and space? exist because parse-f64 and trim need them, and calc-me loses
its own byte-identical digit?. One top-level namespace makes the second
definition an error rather than a shadow, which is the rule doing its job: two
copies that later drift apart is exactly what it prevents.
2026-09-11 19:42:04 +07:00
5f4005e61d A stopped program, driven from Emacs
The break loop was reachable from a raw socket. This is the half that makes
it reachable from an editor, and it all follows from one fact: a program
stops at a moment nobody asked about.

So the state is learned twice, on purpose. It rides on every reply, beside
the program's output and for the same reason -- the likeliest instant for a
program to stop is the one just after an evaluation, which is a reply the
client is already reading, and learning it a second later from a poll would
mean learning it after the echo area had said the evaluation was fine. And a
timer asks anyway, once a second with `describe', because a program that
stops in a frame of its own game loop produces no reply at all and folding
state into replies that never come says nothing. The timer never reconnects
-- that would quietly erase the `lost' state that exists to be seen -- and
skips while a request is in flight, since accept-process-output runs timers
and a poll firing inside a read would eat that read's reply.

Three ops: `break' for the restart names, `restart' and `abort'. The
annotation owns :stopped and :condition rather than the ops, so one place in
the daemon decides whether the program is stopped and the poll and the prompt
cannot disagree. "ok" from `restart' means accepted, not resumed: the choice
is validated against the stopped stack and taken when that thread next comes
round, so it says so and the client clears its own flag rather than polling
once, finding it stopped, and re-opening the prompt it just answered.

The agent grew one verb, `status', answered in both states. Everything else
the break loop offers is refused while running, rightly; but the question an
editor asks without already knowing had to have an answer either way or there
would be nothing to poll.

And flan_agent_poll had to become re-entrant, which was a bug rather than an
addition. A C-x C-e thunk may itself error, and the break loop that catches
it polls again from inside that call. The old loop cached both indices and
stored tail at the end, rewinding over everything the nested poll consumed --
re-running the thunk that had just stopped the program, which is an unbounded
recursion of breaks. Each job is now claimed before it is run. test_dev.ml
evaluates an expression that errors and resumes it, which fails against the
old shape.
2026-09-11 19:39:29 +07:00
9700eeafb4 Images, because pixels in RAM are what a headless test can argue with
Every other struct in the package is handed to raylib and handed back, and
that proves nothing: store-and-return is symmetric, so C writes and reads the
same wrong slots for any field order. An Image is different. raylib computes
with it, and two computations answer differently per axis.

gen-image-color takes two scalars and returns a struct reading 4, 2, 1, 7 —
four distinct values in four adjacent i32 slots, with no input struct for a
permutation to cancel against. Texture2D never got that: nothing without a GPU
reads its width, height or mipmaps at all.

And get-image-color indexes y*width + x, so on a 4-wide, 2-tall image (3,0)
exists and its transpose does not. That is the axis discriminator the
collision family could not be — exchange x and y in the wrapper and the read
goes out of bounds. The two flips say it twice more: on two rows, one moves a
mark the other leaves alone.

The PNG round trip is not the symmetric trap either. stb's encoder and decoder
are external ground truth; they agree with each other, not with whatever field
order Flan believes in.

Verified to fail, each restored after: width against height, mipmaps against
format, x against y in the shim, the two flips bound to each other, and the
crop rectangle's width against its height.
2026-09-11 19:35:19 +07:00
386d9e0372 Call every collision binding at least once
Six of them were bound, linked, and had never been called by anything. That is
the state a wrong argument order survives indefinitely: the link succeeds, the
program runs, and the answer is nonsense that nobody has looked at. An audit for
wrappers with no caller is worth doing after any binding lane.

All six turned out to be correct, which is worth recording either way - the
point of the audit is not that it finds bugs but that it converts "probably
fine" into "called, and the answer checked".

Each has a case that must come out the other way, because a predicate that
always said yes would pass a single one.

The one that earns the most is the polygon, the only binding here that crosses a
slice, so the only place ptr+len has to arrive as raylib's pointer and count.
Everything else about it would pass with a hardcoded count or with the pointer
alone; the same point against the same array with three corners instead of four
is what pins the length. Verified by hardcoding the count in the shim and
watching it go the wrong way.
2026-09-11 19:20:13 +07:00
27172d260f Collision bindings, and what a headless FFI test cannot pin
Finishing the 2D lane's unfinished work: the collision family was written and
had no tests when the session ended. It is the best material a headless table
gets, since every one of these is pure and needs no GL context.

Two plausible tests in a row turned out to check nothing, and that is the part
worth keeping. A struct round trip is symmetric and passes for any field order -
the texture lane found that one. The second is subtler: no axis-aligned geometry
can pin Vector2's fields, because exchanging x and y is a reflection that is
applied on the way in and undone on the way out. Swapping the shim's own typedef
leaves every collision case passing. Distances never even see it.

What does pin Vector2 is the rotated camera, because a rotation is not
axis-aligned and does not commute with the reflection. That case is load-bearing
and the comment now says so, because the collision cases look like they cover
the same ground and do not.

What the new cases do pin is Rectangle, completely: swapping width and height
turns three of the four predicates the wrong way. Verified by doing it.

collision-lines answers (Option Vector2) rather than a bool and an
out-parameter, because raylib leaves the out-parameter untouched when the
segments do not meet and a caller who forgets reads whatever was there.
2026-09-11 19:01:32 +07:00
f5d4cd5188 Camera2D, and a test that pins its layout by arithmetic
GetScreenToWorld2D computes from every field of a Camera2D - offset, target,
rotation and zoom - so a wrong field order produces a wrong coordinate rather
than the same numbers back. That is the standard the texture lane arrived at
the hard way: a struct round trip is symmetric and passes for any layout.
2026-09-11 18:57:54 +07:00
b34cd6de58 Slice algorithms and number parsing, in Flan
The standard library's first real content beyond printers. Everything is
in-place over a slice, because there is no allocator and nowhere to put a copy
- and a slice aliases its owner's storage, so sorting one sorts the original.

No generics means no single sort. Each is spelled per concrete type, which is a
cost paid deliberately rather than worked around.

parse-i64 is written in Flan rather than bound to strtoll, which answers 0 for
an empty string, for a string of letters, and for a genuine zero, and reports
overflow through errno.
2026-09-11 18:57:54 +07:00
7fcda905ff parse-i64 in Flan, because strtoll answers 0 four different ways
bytes->i64 is strtoll behind a primitive, and strtoll returns 0 for "", for
"abc", for a lone "-", and for the "12" in "12x". None of those is
distinguishable from a real 0, so any program that parses input it did not
write is already wrong and has no way to find out. parse-i64 takes the whole
slice or refuses it and says so with None. It is also the version that answers
the same on wasm32: strtoll is libc's and locale-sensitive, which is the same
argument that put the PRNG in the prelude rather than leaving it to rand().

The byte predicates are over [u8] rather than over string on purpose. (bytes s)
is one call at the call site, and in exchange one copy of each function serves
strings and byte slices both — which is as near a generic as this gets. Each
tests its length before it slices, and `and` short-circuits, so a prefix longer
than the subject answers false instead of tripping the slice bounds check.

sign-f32 and lerp are the only two numeric helpers here, because they are the
only two that decide something. clamp is (min hi (max lo x)) and abs is
(max x (- 0 x)) over builtins that already exist — a prelude wrapper is a
function emitted into every program to save a caller nothing. sign-f32 answers
0.0 for NaN, which is a choice and is written down. lerp is the weighted sum
and not a + t*(b - a): the latter does not land on b exactly at t = 1.0, and a
position that never quite arrives is what interpolation gets bug reports for.

floor, ceil and round are deliberately absent. (f32 (i32 x)) is fptosi, which
is poison out of range, and shipping that as a documented limitation is the
same class of bug NEXT.md already records twice under Sharp edges. Correct
lowering is llvm.floor.f32 in emit.ml, which is not this lane. sqrt is absent
for a different reason: it is an extern to libm, and what libm means on wasm32
is a decision the FFI owns, not the prelude.

rand-i32-range answers lo for an empty or reversed range rather than dividing
by zero, which is immediate undefined behaviour and not merely a wrong number.
Both range functions draw exactly one rand-u32 and neither changes it, so the
sand hash still pins the generator; the new test pins the derivations off a
fixed seed, which nothing else would have caught.
2026-09-11 18:49:52 +07:00
856f7dad7b A camera whose layout a round trip could not have pinned
GetScreenToWorld2D and GetWorldToScreen2D are pure arithmetic over every
field of a Camera2D, so they run with no window at all — the best headless
material the package has had. Both directions are asserted as absolute
answers rather than as a round trip, because an inverse cancels a permuted
layout exactly the way store-and-return does.

The rotated case earns its awkwardness: exchanging x and y in Vector2
mirrors every component-wise formula and the answer comes back mirrored
too, so nothing until now could tell the two floats apart. A rotation mixes
them. It reports ok/bad against a tolerance because 90 degrees goes through
sinf and the answer is 27.9999981, and the table compares stdout byte for
byte at -O0 and -O2.
2026-09-11 18:48:57 +07:00
84e170b349 Slice algorithms in place, because there is nowhere to put a copy
A sequence library normally returns new sequences. There is no allocator, so
every one of these mutates the storage it was handed and a slice is the handle
that makes that useful: (slice grid 4 9) is ptr+len into grid, so sorting it
sorts those five elements and leaves the rest of grid alone. The test asserts
exactly that — it sorts a subslice and prints the whole owning array — because
it is the property that would die silently the day a slice parameter started
being copied rather than passed by value, and -O2's mem2reg would hide it.

Insertion sort rather than anything faster. Quicksort wants a stack and
mergesort wants a buffer, and neither exists; insertion sort needs a swap and
two indices. It is also the only one of the three whose inner loop is short
enough to read, which matters more than the asymptotics on the slice sizes a
frame loop actually sorts. The `and` guarding it short-circuits, and that is
load-bearing: at j = 0 the left test fails and (at s -1) is never evaluated,
so the bounds check never fires.

Over [i32] and nothing else. There are no generics, so a second element type
is a second copy of all seven functions emitted into every program that links
the prelude, and i32 is the type indices, ids and tile values already have.
An f32 set waits for a program that wants one.

min-i32 and max-i32 return (Option i32) rather than a sentinel because there
is no i32 that means "the slice was empty" and is not also a possible element.
sum-i32 accumulates in i64 and widens each element explicitly — there is no
implicit widening anywhere, and an i32 total over a screenful of i32 is how a
sum wraps without anyone noticing.
2026-09-11 18:48:11 +07:00
200aef5b9f A crash stops the program instead of killing it
spec-conditions.md §2, and the reason the transfer was worth building. An
unhandled error runs a hook instead of rt_die(), on the frame that erred with
nothing unwound, lists the restarts between there and the top, and waits.

A hook rather than a direct call because the loop lives in vendor/agent, which
is an optional package, and flan_rt.c is the release runtime - a program with no
agent leaves it null and dies the way it always did. The hook resumes by writing
a restart into the transfer channel, which is the channel an invoke-restart
writes and reaches the same guard, so choosing from the break loop and choosing
from a handler are one act lowered once. §6 needed no change.

The break loop is the poll loop, run from the error rather than from the frame
boundary. That is load-bearing: an expression evaluated while stopped is a
module the listener queues and the game thread runs, so a loop that did not
drain that queue would hang C-x C-e exactly when it is wanted most. Installing
while stopped is allowed, which contradicts the rule that a redefined function
must not be swapped while it is on the stack - that rule is about mid-frame
consistency and there is no frame in progress here. The old body keeps running
and a retry reaches the new one through the cell, which is the whole point.

A restart frame carries its name now, beside the hash. Matching never needs it;
showing someone their choices does, and nothing at run time can turn a hash back
into a name.

A choice is checked on the listener thread against a stack the stopped game
thread is holding still. Answering ok and finding out on the game thread that
nothing offers that name would report success for something that cannot happen.

The test errors twice and takes a different restart each time, so a loop that
always resumed the same way fails it.
2026-09-11 18:47:41 +07:00
d9b035be3c Two collapsed line continuations, and a branch that could not fire
The extern-boundary refusal and a restart-case one each had a run of spaces
mid-sentence where an OCaml line continuation had been lost. The textures lane
found the first while reading the FFI rules; the second was mine, from the same
editing mistake in the same week.

The restart-case one turned out to be unreachable as well. Each clause is
checked with the type the form has already settled on, so a clause that
disagrees is refused by expect where it is written - the dedicated message
could only have fired if that check did not, and it does. Removed rather than
reworded; a message nothing can produce is worse than no message, because the
next person tries to work out what reaches it.
2026-09-11 18:01:23 +07:00
dd556bfda2 Textures and sprites, and a test that can actually fail
Eleven bindings: the two structs raylib needs for them, load/valid?/unload, four
draw forms, and the shapes-texture and collision-rectangle calls. IsTextureReady
does not exist in raylib 5.5 - it was renamed - so IsTextureValid is what is
bound; the old name would have been a link error.

The test is the point. The obvious one - hand raylib a Texture2D, read it back,
compare - passes for any layout, because store-and-return is symmetric and C
writes and reads the same wrong slots. Permuting two fields in the defstruct
produced identical output. What replaced it makes raylib compute something from
the fields: GetCollisionRec pins Rectangle completely, four numbers from four
different field pairs, and SetShapesTexture's default substitution pins the id
against the rest of Texture2D. Each was verified by permuting fields and
watching the test fail.

The limit is stated where someone will find it: width, height and mipmaps are
not pinned against each other, because nothing raylib computes without a GL
context reads them. A width/height swap shows only as a visibly wrong sprite.
That half is verified by running sand under Xvfb and looking, which is recorded
as manual and not asserted anywhere.
2026-09-11 18:00:18 +07:00
f8adf6d980 The Emacs client, made comfortable to sit in
Error overlays where the error is, connection state and reconnection, visible
confirmation of what landed, and eldoc, completion and M-. off one cached reply.

Two latent bugs had to be fixed to put an overlay in the right place. A :loc
column is a byte offset - reader.ml advances per byte - and the client was
using it with forward-char, which is the framing bug in a second place. And the
daemon numbers lines from the start of what it was sent, so C-c C-c on a defn
halfway down a buffer answered line 1 and every overlay would have sat on the
file's first line; the form is padded with leading newlines, which the reader
skips, so the reply's line numbers are the buffer's own and no protocol changed.

Reconnection happens before a send and never after. A request lost in flight
may already have run, so resending it would install twice or run a
side-effecting expression twice; that case says what happened and says it was
not resent.

defs is its own op rather than more fields on describe, because describe is
polled to drain the program's output and signatures should not be paid for
every time anyone looks at that buffer. Globals and externs carry no Loc in the
Tast, so M- refuses on them by name with that reason rather than guessing a
file by searching for the text.
2026-09-11 18:00:07 +07:00
16f1605580 The FFI case at -O0 too, where the allocas are still there
Every other program in the table is run twice for the reason stated next to
them: at -O2 mem2reg launders a sloppy alloca, so -O0 is what tests the IR
actually emitted. The raylib case had been the exception, and it is the worst
one to exempt — five of its calls hand C the address of a local struct, which
is exactly the alloca that comment is about.

It passes as it stands. That is the point: the case that only ever ran
optimised was a coincidence away from hiding something.
2026-09-11 17:59:00 +07:00
Joseph Ferano
6f3ec2bb91 Keep the indicator, eldoc and the cache to buffers that asked for them
Three things the first cut got wrong by being global when it had no business
being.

The modeline entry was added to `mode-line-misc-info' at load. It returns nil
outside a Flan buffer, so it was invisible — but it was still evaluated on
every redisplay of every buffer in the session, for someone who loads the
client and then spends the afternoon in dired. It is installed buffer-locally
by `flan-dev-setup' now, which already runs in exactly the buffers that want
it. The `derived-mode-p' guard stays: cheap, and it keeps the function honest
wherever it is called from.

`flan-dev-setup' switched eldoc on. Contributing a documentation source is
this file's business; whether eldoc runs at all is the user's, and turning it
on overrules someone who has `global-eldoc-mode' off deliberately. It is on by
default, so nearly everyone gets the same behaviour either way.

And a reconnect forgot the name cache without asking for it again. An empty
cache is honest but silent — eldoc goes quiet, M-. falls through to whatever
else is registered, and nothing says why — until the next install happens to
refill it. It refreshes straight after reconnecting, which is safe from there
because the connection is live by that point and the request does not come
back round through the same function.
2026-09-11 17:58:34 +07:00
Joseph Ferano
7118d6106d eldoc, completion and M-. off one cached reply
All three want the same three facts about a name — what it is, what it looks
like, and where it was written — so the daemon answers all three in one
`defs` reply and the client keeps the last one.

`defs` is its own op rather than more fields on `describe`. `describe` is what
an editor *polls*: it is how the program's output gets drained, and the
existing tests ask it in loops. Signatures riding on that would be paid for
every time anyone glanced at the output buffer. This is asked once on connect
and again after each accepted install, which is exactly when the answer can
have changed — so a `defn` typed a second ago completes.

It is a cache rather than a request per keystroke because of where these are
called from: eldoc fires on an idle timer and completion inside redisplay, and
neither may block on a socket or signal.

Three refusals rather than three guesses. A global has no location because
`Tast.global` carries no `Loc`, and searching the buffer for "(defvar ticks"
instead would find the wrong one in a program of several files. The prelude is
a string inside the compiler, so its location names a file nobody can visit. A
short name that could be several of the program's package-qualified ones is
ambiguous, and picking would be a guess about which function you meant — a
name that is the tail of exactly *one* is not a guess, and resolves.

Functions the checker invented — a lifted handler-bind clause, which carries
an `fparent` — are left out entirely: nobody wrote that name, so completing it
is noise and jumping to it is meaningless.

And the daemon now makes its own source path absolute before building, because
every location it reports derives from it. `flan dev src/game.flan` from a
project root answered `src/game.flan:12:7`, which an editor can only resolve by
guessing what it was relative to.

lib/dev.ml is the only compiler file touched: a `defs` op, its three list
builders, and the one `realpath` in `start`. Nothing existing changed shape —
`describe`, `eval` and `eval-expr` answer byte for byte what they did.
2026-09-11 17:56:37 +07:00
01603843c0 A sprite in sand, because running it is the only test there is
sand.flan now loads a 16x8 sheet of two 8x8 brush frames and draws it four
ways: the frame under the cursor through draw-texture-rec, and three badges in
the corner through draw-texture, draw-texture-v and draw-texture-ex. That is
not decoration — it is one call site per binding that the acceptance table
cannot reach, and without it draw-texture-v and draw-texture-ex would be code
nobody had ever executed.

A failed load says so by name. LoadTexture on a missing file returns an id of
0, and every draw with that texture silently does nothing, so the program
would look like it had a drawing bug rather than a missing file. texture-valid?
is asked once at load and the answer is both printed and remembered, so the
sand still runs with the cursor off.

brush.png is generated rather than drawn — two circles, one ring and one
filled, 102 bytes — so the repository gains an asset nobody has to keep.

What this was checked by: xvfb-run, a screenshot of the running window, and
the badges counted in it. Also with brush.png moved away, which is how the
refusal path above is known to fire rather than merely to compile.
2026-09-11 17:56:03 +07:00
a178135143 Textures, which cannot be tested without a GPU
LoadTexture, UnloadTexture, the four DrawTexture variants and IsTextureValid.
Nothing about them pushes against the aggregate rule: every raylib signature
here takes its structs by value, and every one has an obvious pointer form the
shim dereferences, so the declarations are scalars and pointers as before.

The predicate is IsTextureValid and not IsTextureReady, which this version of
raylib does not export at all — 5.5 renamed it, and calling the old name would
be a link error rather than a silent miss. It is bound because the failure it
reports is otherwise invisible: LoadTexture on a missing file returns a texture
with an id of 0 and says so only on the trace log, and then every draw with it
is a no-op that looks like a drawing bug.

cstr's one caller used to be the window title, and its comment said so. A path
is the second caller and wants far more than 256 bytes, so each caller now
passes a buffer sized for what it holds. Truncating still beats reading past
the end: a truncated path simply fails to open, and texture-valid? is how the
program notices.

None of this is in the acceptance table, and deliberately. Loading a texture
needs a GL context, so anything headless would be asserting on the failure
path while appearing to test the working one. It is exercised by sand.flan.
2026-09-11 17:52:51 +07:00
07d068d297 A struct that comes back unchanged proves nothing
Texture2D and Rectangle are the two structs the texture calls need, and they
are the ones whose layout can be silently wrong: five 4-byte fields in a row,
and four floats in a row, so a permutation still reads as plausible numbers
everywhere.

The obvious test — hand raylib a struct, read it back, compare — is worthless
here, and I only found that out by trying it. Storing and returning is
symmetric: swap two fields in the Flan defstruct and the round trip still
agrees with itself, because C writes and reads the same wrong slots. That test
passes whatever the layout is, which is the kind of test this project would
rather not have at all.

So the headless case uses the two things raylib computes from the fields
without a GPU. GetCollisionRec turns (0,0,10,4) and (6,1,10,10) into
(6,1,4,3), four different numbers each derived from a different pair of
fields, and no permutation of Rectangle survives it. SetShapesTexture keeps a
Texture2D without touching GL and substitutes 1 1 1 1 7 when the id is zero,
so a zero id pins the first field, the 7 pins the last, and a zero width
stored rather than substituted is what stops that pair from passing with id
and width swapped. Each of those was checked by permuting the defstruct and
watching the case fail.

What is left unpinned is width, height and mipmaps against each other; nothing
raylib does without a GL context reads them. That is stated in the program
rather than papered over, because the alternative is a case that looks like it
covers them.

set-shapes-texture, get-shapes-texture, get-shapes-texture-rectangle and
get-collision-rec are real bindings, not test scaffolding — they are bound
here because they are also the only pure consumers of these two structs.
2026-09-11 17:52:04 +07:00
Joseph Ferano
9e7eba1479 Say what landed and what it cost, and flash the form it came from
An install that reports nothing is indistinguishable from one that failed
silently, which is the one thing this loop cannot afford: the whole promise is
that the running program now has the body you just wrote.

The names come from the reply rather than from what was typed, because the
daemon is the one that knows which of them it installed — a `defvar' the
program already had is not among them, and the reply already says so with
`:note'. That case now reads "nothing to install" instead of quoting a
build time for a build that did not happen. `:fns` and `:names' are reported
separately for the same reason: a buffer of five functions and two vars
should not report as five of anything.

A long list is counted and then sampled rather than truncated, since an echo
area cut off in the middle of the tenth name tells you neither how many there
were nor which.

And the region that was sent is flashed, which answers a question the echo
area cannot: `beginning-of-defun' may well have found a different form from
the one you thought point was in.
2026-09-11 17:50:00 +07:00
Joseph Ferano
12f99702b4 Say in the modeline whether there is a program, and reconnect to one
Whether a program is on the other end is the one fact worth a permanent place
on screen, because every command in the client is a lie without it. Until now
it was discovered by something failing, which is the worst moment to learn it.

Three states, not two. `off' is never connected; `lost' is a daemon that has
gone away, which is the ordinary case rather than an error — `flan dev' ends
when its program does, and a program under development exits all the time. So
`lost' is reconnected from, on the socket it was on, the next time anything is
sent.

The reconnect is strictly *before* a send and never after one. A connection
that dies mid-request might have died after the daemon took the request and
ran it; resending would install a definition twice, or evaluate a
side-effecting expression twice. That case now reports what happened and says
it was not resent, rather than silently doing it again.

A socket that is not there is refused by name with the path, and a deliberate
`flan-disconnect' forgets the socket, so the next command says "not connected"
instead of quietly reopening what was just closed.
2026-09-11 17:48:06 +07:00
Joseph Ferano
aab6c28450 Show a rejection where it is, on the line it is actually on
An error that only reaches the echo area is gone the moment you type, and the
location was the useful half of it. So the client draws an overlay at the
`:loc` the daemon sent, with the message beside the code, and clears it the
next time that buffer's evaluation is accepted — a marker left behind after a
fix is a lie about the running program.

Two things had to be right first, and neither was.

The column in a `:loc` is a *byte* offset: lib/reader.ml walks the source a
byte at a time and OCaml strings are bytes. The old code did `forward-char`
with it, which is the same mistake as counting a frame's length in characters,
in a different place — one accented character earlier on the line puts the
marker as many columns to the right. It goes through `byte-to-position` from
the line's start now, and is clamped to the end of the line, which the old code
also needed: a column past a short line walked into the next one and pointed at
innocent code.

And the daemon numbers lines from the start of what it was *sent*, so `C-c C-c`
on a defn halfway down a buffer came back saying line 1. Every overlay would
have sat on the file's first line. The fix is leading newlines: the reader
skips them, and the reply's line numbers are then the buffer's own. No protocol
change, and nothing the daemon has to know.

Marking the error must not itself signal — the error the caller is owed is the
daemon's, and losing it to a bad location would report the wrong thing.
2026-09-11 17:46:35 +07:00
a1285a5ac6 The commit count again 2026-09-11 17:35:28 +07:00
59e9392ec8 What the class decision changes for the next session
plan.org's managed classes arrived after NEXT.md's handoff was written, so a
session reading the plan cold would take them as the next task. They are not:
plan.org's own last line on them says nothing until struct, Handle and reload
semantics work, and that belongs where the next task is named.

Three findings from reviewing it that are not in plan.org. A generic function is
a cell whose body is a dispatch table - adding a method later is the same
problem the indirection cells already solve, so the expensive half of classes is
built. Migration has to enumerate live instances, which makes the pool behind a
generational Handle the only one of the three storage options that obviously
supports it, rather than a free choice. And a numbered layout has to stay
resolvable for migrate to dispatch on, which is the same retention rule as
nothing is ever dlclosed.

Also records the open question the class facility raises for conditions: whether
a condition may be a class, what the hierarchy would buy, and the three costs -
allocation on the signal path being the serious one. It wants answering before
handler-case, since it decides whether handler matching has one path or two.

Plus three nits in the new prose: float/int are not Flan type names, the place
syntax was dotted, and the migration example set a slot the class did not have.
The tag-word sentence said any was the only place one is paid, which Error and
now a class instance both make false.
2026-09-11 17:35:06 +07:00
ac8d31ee65 Managed classes, planned and deliberately separate from structs
The struct/class split, written down before anything is built on it. A struct
stays a fixed-layout value with C's layout, which is what keeps the FFI, SoA and
wasm stories intact; a class is a separate kind with identity, metadata and an
implementation-defined representation, for the long-lived gameplay objects that
want to change shape while the program is running.

The tagline loses "no GC" for "no mandatory GC", because a small collector
confined to class instances is now an option rather than a contradiction. CLOS
goes from a flat non-goal to a bounded one: the metaclasses, method combination
and arbitrary change-class are out; exact-class single dispatch and an explicit
frame-boundary migration are in.

Migration is eager and explicit rather than CLOS's lazy-on-access, which would
put a check on every slot read. Class identity is stable and layouts are
numbered, the same shape as the function versions the hot reload section grew.

Nothing is frozen and nothing is to be built until struct, Handle and reload
semantics are working.
2026-09-11 17:34:13 +07:00
b34d0d7d59 Say that refusing a signature change is a stopgap
plan.org's hot reload section now says a signature-changing redefinition should
make a new internal function version with its own trampoline: new code resolves
the name to it, existing callers and stored Fn values keep the old one safely,
and the session warns at every tracked caller site still on the old signature.
session.ml refuses the change outright, with a reason that reads like the final
answer.

None of the three parts exists - no function versions, no trampolines, and no
record of which source location called what - so the refusal stays, because the
alternative to refusing is not the new design, it is a silent argument
mismatch. What changes here is only that the code and NEXT.md now say which one
it is, so the next person reads it as the stopgap it is.
2026-09-11 12:26:28 +07:00
55d7c2fae8 The REPL renderer is most of println
plan.org puts a compiler-provided, type-directed println at milestone 5: a
structural printer selected or emitted per concrete instantiation, Ptr printed
as an address rather than followed, depth and length bounded. That is a
description of the renderer C-x C-e already has - same walk, same refusals, the
same three bounds - pointed at flan_dev_emit and the wire instead of at stdout.

Recorded next to the renderer so it is not built a second time. What println
needs on top is a stdout sink, a builtin that takes its printer from the
argument's type, and the any/Error cases, which have no compile-time type to
walk.
2026-09-11 12:25:59 +07:00
f55ec0a0b8 Why a handler frame holds a body address
plan.org now says a top-level function value is a stable trampoline over the
indirection cell and never the address of a particular body, so that a stored
callback observes a redefinition. A pushed handler frame breaks that rule and
should: it is not a Fn value, nothing in the language can name it, and it is
live only for the duration of the handler-bind body - so a reload landing while
it is on the stack finds the clause it pushed still valid, which is the whole of
old code is never unloaded.

The consequence worth knowing is that a handler already on the stack does not
pick up a redefinition of its own clause; the next entry to the handler-bind
pushes the new one. Recorded at the store in emit.ml and in NEXT.md, because
when Fn values arrive this is the one place that stores a body address on
purpose and must not be swept up with the rest.
2026-09-11 12:25:28 +07:00