607 Commits

Author SHA1 Message Date
50798aac89 A generic sort takes its comparison as a value, and an operator over a variable is refused 2026-09-13 13:20:12 +07:00
cb56fc14b1 Generic functions instantiated at their call sites, spiked 2026-09-13 13:13:10 +07:00
6de6c079b0 What the "never listened" flake actually was, written where it was hunted
Two lanes were sent after a contention race on a socket path. There is
none.  The sentence came from three sites and not the two HANDOFF-f1.md
named — test_dev.ml:77 checks the *dev* socket with exactly that wording,
in the same block whose *agent* socket check was fixed — and the cause is
a bound too short for a build: flan dev links the whole program before it
binds, ~600ms warm, measured at 6.5s here and 6.6-6.8s by the lane that
fixed it, against a 5000ms await in test_dev.ml and 8000ms in the other
two.  Build.cachedir sits under dune's per-run TMPDIR, so that build is
cold on every invocation and the failure is not occasional at all.  The
fix landed on another branch; nothing in this commit touches a test.

The agent.sock ordering fix it does confirm: 30 sequential full dune test
runs, 0 failures, no "never bound" in any log — so NEXT.md's "One flaky
test, measured rather than suspected" section, left standing for whoever
confirmed it, goes.

HANDOFF-f1.md's "lengthening any timeout" bullet is qualified rather than
deleted.  It was true of the agent.sock check and read as a rule, and
reading it as a rule is what kept this open for a second lane.
2026-09-13 13:12:17 +07:00
7be57efe18 A type variable is marked, because absence is a bad thing to give meaning to 2026-09-13 13:09:25 +07:00
f2be0a62dd A pause is waited for by name, and a build is not a socket
Two follow-ups to the marking commit.

`Dev.eval_expr`'s new wait matched `Stopped _`, which fires on the first
iteration when the program is already parked on something else — the
break loop allows evaluating, so that is reachable — and answers for a
thunk that has not run yet, on a reply whose own `:condition` names the
other condition. It now waits for `Stopped "Pause"`, which the agent
reports under a nested break because `condition_name` is overwritten on
the way in and restored on the way out. `dev-pause.flan` grows a
`Missing` and a `boom` so the test can park the program on something
else first and tell the two apart.

And the flake NEXT.md had as "seen once and unexplained": `the daemon
never listened` is not a race, it is an llc-and-link of the whole
program before `flan dev` binds — ~600ms idle, measured at 6.6s and 6.8s
with the rest of the suite beside it, against a 5s and 8s await. All
three test binaries now wait a minute; the watchdog is what bounds the
run. Two consecutive full runs green.
2026-09-13 13:07:25 +07:00
6e7b53b988 A numeric watch has a site, and an empty window has none
`flan-watch-ghost-call-regexp' allowed one hyphenated segment until this
lane's sibling widened it, so `watch-num-i64' matched as far as
`watch-num', required whitespace, found `-i64' and backtracked to
failure: the row appeared in the watch buffer as usual and the call site
got no inline value at all.  Nothing automated covered the widening.  It
does now — a block pinning both accumulator heads beside the four
one-segment ones, verified by putting the `?' back and watching exactly
those two checks fail.

The other half of that handoff asked for a test of `watch_render_num''s
`n=0' branch.  It cannot be written: `s->num' is set in one place,
`watch_record', which always falls through to `s->n += 1', and the clear
and the increment sit inside one odd-generation window.  The reader
never compares the slot's epoch to the global one, so a reset does not
make a slot read as empty either.  The branch is dead, and the live
consequence is that a read taken after `:reset t' and before the
program's next sample reports the previous window — which is why the
existing test waits for the count to drop rather than reading once.
Recorded in HANDOFF-f3.md with the two ways out, both of which belong to
whoever owns runtime/flan_dev.c.
2026-09-13 12:56:26 +07:00
97cb77d949 Five gaps the examples hit, written down before they are forgotten 2026-09-13 12:54:42 +07:00
1df6f7a642 Five more core examples, and the 3D half of the bindings they wanted
core-2d-camera, core-scissor-test, core-window-flags, core-world-screen
and core-window-should-close, ported from raylib 5.5's examples/core.

What each one asked of vendor/raylib:

  2d-camera        nothing. A whole Camera2D by value, per frame, into the
                   call that actually draws with it — the layout the
                   acceptance table pins through arithmetic, now going
                   through the path it was bound for.
  scissor-test     begin-scissor-mode / end-scissor-mode, hand-written.
  window-flags     twelve more ConfigFlags constants; raylib.flan carried
                   the four sand.flan sets and this reads eleven.
  world-screen     the 3D surface did not exist: no Vector3, no Camera3D,
                   and the importer refused every 3D function in raylib.h
                   by name for want of them. Two defstructs, two defenums,
                   begin/end-mode-3d, update-camera, get-world-to-screen,
                   draw-cube, draw-cube-wires, draw-grid — and 24 more 3D
                   lines the importer can generate now that the types are
                   described.
  window-should-close  set-exit-key, and Key/null to pass it.

The hand-written/generated line, written down in vendor/raylib/bindings:
a drawing pair inside a frame is hand-written, and so is anything whose
Flan face is not the C signature — set-exit-key takes a Key, update-camera
takes a (Ptr Camera3D) and a CameraMode. The window-state family and
get-mouse-x/y stay generated: plain scalars in and bool out, with nothing
for a hand-written line to add.

Camera3D's projection field stays i32, because the header says int and the
layout check holds this file to that; rl/camera-projection is the
conversion, and still takes a keyword.
2026-09-13 12:51:19 +07:00
5791faee4e A breakpoint is a function call, and the editor only says where
Finishes DISCUSS.md §9's `pause` marking: the daemon half was already
built, this is the editor half plus the one daemon path it was missing.

`C-u C-c C-c` marks the form point is inside, `C-u C-u C-c C-c` the
top-level form (stop on entry), `C-u C-x C-e` the expression before
point. The buffer is never edited — the position rides beside the code
and the `(pause)` call goes into the tree after parsing, so no source
location moves.

`C-x C-e`'s path needed the daemon: its 5s `wait` answered "the program
did not reach a frame boundary", which is exactly what a thunk parked at
a breakpoint looks like from out here. `wait` is now three-way and asks
`state t = Stopped` only when a pause was requested, so the no-pause
shape `test_dev.ml` pins is unchanged.

The overlay is an annotation and not feedback, so unlike an error marker
it survives `pre-command-hook`; what takes it down is an accepted
evaluation with no `:pause` on it, which is the same thing that takes
the mark itself down.

Tests: a `test_dev.ml` block over the new `dev-pause.flan` that marks,
stops, re-evaluates plainly and then polls half a second confirming it
does not stop again — one sample after `continue` proves nothing, the
resumed frame is still in the old body — and an `emacs/test-flan-dev.el`
block for which form a prefix picks, the byte column, the overlay's
lifetime, and one live round trip.
2026-09-13 12:51:06 +07:00
191dcc1246 An abandoned frame rolls back, and restore goes after the defers
PORTING.md Tier 1 item 6, the last one on that list. Not a language
feature and nothing was added to the language: restart-case, struct
assignment and fixed-arrays-as-values were already built, and what was
missing was the worked example. test/programs/frame-rollback.flan is it
— snapshot at the top of the frame, restore in the `continue` clause,
over one fixed array and one struct, which is engine.clj's grids plus
engine.lisp's shallow copy of the state object. Two `set`s each way,
because both are values; there is no IntGrid walk and no
sb-mop:class-slots walk to write.

The decision in it is the ordering against defers, and both orderings
compile. An answered bounds failure runs the abandoned function's
defers, innermost-first, before the restart clause body starts. Restore
in the clause is therefore the last write on the abandoned path and
needs no agreement with what any defer did on the way out. The rejected
alternative — restore in a defer inside the frame function — also runs
on the ordinary return path, so it rolls back the frames that
succeeded, and nothing reports that as an error.

Pinned with numbers rather than prose: a tick counter inside the
snapshot, written by the frame's defer, reads its pre-frame value,
while a counter outside the snapshot shows the defer ran. And there is
a negative control, the same bad frame with a `continue` that only
counts, because "state equals snapshot" passes trivially on a program
that never wrote anything.

Three acceptance rows beside bounds-condition.flan's, for the same
reasons: -O0, and the dev build where every call goes through a cell.
2026-09-13 12:44:02 +07:00
a016c3f226 Queue what four lanes left, and where each one wrote it down
Three handoffs at the root carry the detail; NEXT.md carries the queue.
F2's Emacs half is seven items with two traps in it, F1 leaves a second
unexplained flake and the sweep that would let its section be deleted,
F3 leaves the two tests its own change did not get. The raylib examples
were never started, and Tier 1 item 6 still has not moved.

Also the worktree problem, because it nearly cost two lanes their work:
at least two were handed out about 485 commits stale, on a tree without
lib/dev.ml. Both noticed. One that did not would look like it worked.
2026-09-13 12:22:17 +07:00
6592cff743 Merge branch 'worktree-agent-aac8d8a2a09e2b151' into dev-loop 2026-09-13 12:19:20 +07:00
bbd1c67474 Merge branch 'worktree-agent-a19373b804f289af0' into dev-loop 2026-09-13 12:19:20 +07:00
74011e6cbe Merge branch 'worktree-agent-adf4bf6c83bd6d2f9' into dev-loop 2026-09-13 12:19:20 +07:00
aa6087af91 sand.flan is the game the other ports are, and nothing else
765 lines to 201, at parity with lisp/sand.lisp, clojure/src/fnm/sand.clj
and src/fnm/sand.jank. Gone: audio and tone synthesis, the brush textures
and the embedded PNG, the render-texture scene, the HUD font, the camera,
the world cursor, the HUD and the input-state read-out. None of it is in
any reference version, and none of it was the language being exercised.

Inlined the single-use helpers -- empty-at?, move-grain, draw-grid, paint.
settle also carried an unused (rl/Vector2 ...) binding, which put raylib
inside the section whose banner says there is none, and a stray (pause).

The physics is untouched on purpose: the references disagree there, so
parity does not name a target, and settle is what the pinned hash covers.
It still prints 15595743031174623232 at -O2, -O0 and as a dev build.

test_web.ml asserted brush.png's bytes reached the wasm module, proving an
embed survives the web build. web-files.flan is web-built and *run* under
node and asserts the embedded bytes print, which is the same property
checked harder, so the sand assertion goes rather than the embed staying.
2026-09-13 12:19:13 +07:00
fc319474b6 A hot loop keeps five numbers, and the window is the editor's
PORTING.md Tier 1 item 5. The spy half of the watch was already built — the
pushed table, the buffer, the inline ghost text. What was missing is spy-num,
which is the part that item calls least obvious and most valuable, and it is
what this is.

A slot keeps count, min, max, last and mean. Each answers a question you can
ask without building a query: n is the first thing wrong when a loop is wrong,
the range is what one sample can never show you, last is what the scalar watch
would have given you, and the mean is a running sum divided at read time
because a mean accumulated as a mean drifts. A small ring of the last N
samples was the other candidate and loses — N out of 91,200 is a sample of the
tail of the loop rather than of the loop, and past five numbers every richer
answer is a UI for building a query.

The write path does no formatting, which is the feature rather than an
optimisation: a snprintf per sample at thousands a frame is a HUD that costs
more than the game. A sample is a load, five compares and the slot's seqlock;
the listener thread renders once per editor tick.

The window is since the editor's last tick, and that is a deliberate
divergence from watch.clj, where the stats are cumulative until reset-spies!.
Cumulative min and max reach the session's extremes within seconds of play and
then never move again, so the two most useful of the five go dead exactly when
you start interacting with the thing you are debugging — and this tool exists
to show you a number while you drag the mouse. Reset is its own message and
never a side effect of reading, because a destructive read makes looking
change what is there and anything that polls would shorten the window under
the editor that owns it. It bumps one epoch counter and clears no slot; a slot
clears itself on its next sample, so the reader never writes the table.

Ghost text needed one character. The call regexp allowed one hyphenated
segment, so watch-num-i64 backtracked to failure and a numeric watch got no
inline value while appearing normally in the buffer.

dune test is green, run twice. HANDOFF-f3.md carries the reasoning, the two
small gaps left behind it, and what did not work on the way.
2026-09-13 10:35:20 +07:00
f80a7a98e7 A reply is the ordering the socket check was missing
test_dev's first block checked for agent.sock without having completed a
round-trip, so nothing put the check after the bind. merged_setup binds the
dev socket and returns; the main thread calls agent/start only after that, and
a connect lands in the backlog because merged_serve does not accept until it
has seen agent.sock itself. 2 runs in 8 failed.

Moving the describe up in front of the check supplies the ordering: a reply
arriving means merged_serve is past its own await, so it has already observed
the path. That closes the second race too — unlinking before merged_serve
looked made it wait out its full ten seconds and warn, which is the ten
seconds separating the passing runs from the failing ones.

0 in 10 after, and every run in the fast band.
2026-09-13 10:33:02 +07:00
6f8a7300d5 A pause mark travels beside the source, not inside it
C-u before an eval marks a form so the program stops when it runs
(DISCUSS.md 9). The mark arrives as a position in a separate :pause field
and is applied to the Ast after parsing: splicing text into the source
would move every line and column after it, and the error overlays, the
layout, the break loop's frame locations and DWARF all read those.

Ast.mark_pause puts a (pause) call at whatever starts at that position --
wrapping a sub-expression in a do, or going to the front of a defn's body,
since a declaration cannot be wrapped. A position that matches nothing is
refused rather than installed unmarked, which would report a breakpoint
that is not there.

It sticks with no extra state: the marked declaration is what goes into
the session, so an ordinary C-c C-c over the same form clears it.

The daemon half only; the Emacs command and its overlay are not built.
HANDOFF-f2.md has the rest, in order.
2026-09-13 10:25:47 +07:00
344e571c8c An x86-64 encoder and frame model, not yet wired to anything 2026-09-13 09:43:15 +07:00
66d315813f The encoder and the frame model for a hand-written x86-64 backend
INCOMPLETE AND NOT WIRED IN. lib/x86.ml is not in lib/dune, so nothing
compiles it and nothing calls it; `dune test --root . -j 1` was green at
the tip this branched from and is unaffected, because no file the build
reads was changed. The module itself has never been type-checked.

What is here: the instruction encoder (integer and SSE, loads and stores
at every width, division, shifts, setcc, rip-relative addressing, rep
movsb), the layout bridge to Emit.lay, the frame allocator, and the
.rodata constant emitters. What is not here: the expression lowering,
the call sequence, the function prologue and epilogue, the assembly file
assembly, the build.ml flag and the differential harness. The header
comment is the design; the second half of the file is missing.

THE INTERNAL CONVENTION, which is the decision hardest to recover from
the code, and which is chosen rather than inherited:

  - Scalars -- integers, bool, ptr, enum, handle, allocator, Fn -- in
    SysV's integer registers rdi rsi rdx rcx r8 r9, then right to left
    on the stack. bool is one byte, zero-extended on load.
  - Floats in xmm0-xmm7, then on the stack.
  - EVERY aggregate by pointer. An argument is a pointer to a copy the
    caller made; a return is a hidden sret pointer in the FIRST integer
    register with every other argument shifted along, and that same
    pointer comes back in rax. Nothing is classified, nothing is split
    across register classes, there is no eightbyte rule.
  - The transfer channel is the last argument of all, a pointer, in the
    integer sequence -- emit.ml's `signature` rule, unchanged. It is a
    pointer to a pointer: main allocates one cell, stores null, and
    threads its address down; a callee that transfers stores non-null
    into it and every caller loads, tests and branches to its pad.
  - Frame: every intermediate value is a frame temporary, bump-allocated
    below rbp with a high-water mark, and the outgoing-argument area is
    reserved once in the prologue. rsp is written exactly twice, by the
    prologue's sub and by leave. So rsp % 16 == 0 at every call site is
    a property of one rounded sub, and the spike's depth counter is not
    needed -- its bug class is removed rather than guarded against.

WHY THE CONVENTION IS OURS TO PICK, confirmed rather than assumed: a dev
build compiled by this backend never emits a .ll at all, and a release
build never runs this backend, so no process holds code from both. The
only boundary that must match SysV exactly is C, and check.ml rejects an
aggregate in a `declare` while the generated shim flattens every struct,
so no Flan-emitted call ever hands C an aggregate. I found no path that
mixes the two backends in one process. I did NOT get far enough to test
that claim by running anything, so it stands on reading build.ml's
`executable` and emit.ml's `signature`, not on an experiment.

WHAT THE MEASUREMENT SAYS, and it is the one new fact this branch has.
spike/backend/hist.ml histograms Tast nodes over a program after Reach
prunes it. Item 15's four buckets undercount what a whole-program build
must do on day one:

  - enum-compare.flan needs Str, Make, Field and Call before it prints
    anything, because the prelude builds a slice to print one. Aggregates
    are not a later row; they are in the first program.
  - loops.flan carries Handled, RestartCase and Signal one each. The
    "no plan" row is in the reachable set of a program that only loops,
    so conditions cannot be deferred behind a whole-program flag.
  - The text primitives (Bytes, I64ToBytes, WriteStdout) are C calls,
    not instruction work, so they are cheap.

WHAT THE NEXT PERSON SHOULD DO FIRST, in order:

  1. Finish the lowering as destination-driven: `eval f e ~dst` writes
     e's value into [rbp+dst] and nothing is ever live in a register
     across a statement. That is what makes aggregates and scalars one
     code path and what keeps the frame model's promise.
  2. Emit an assembly file -- .byte blobs with `call sym` and
     `.long lbl - . - 4` for the few relocated fields -- and add the
     flag to build.ml as FLAN_X86 plus an `opts` field, off by default.
     Do not write an ELF writer; it produces no Flan progress and a bug
     in it looks exactly like an encoding bug.
  3. Copy test/test_sanitize.ml's shape for the differential harness.
     There is no differential run yet, so nothing about correctness has
     been demonstrated on this branch.
  4. Bounds checks are implementable and should not be skipped:
     flan_bounds_error(ptr, i64, i64, i64, ptr) and flan_slice_error
     take the transfer channel, so they are an ordinary guarded call.

THE TWO LANGUAGE PREREQUISITES, unchanged and still not decided here.
Uninit is the one that bites: this backend gives whatever the stack slot
held, LLVM may reason from poison, and that is the one construct where
the two backends are supposed to differ. Division by zero, INT64_MIN/-1
and the float-to-int cast are the other three that x86 answers
differently from LLVM's "undefined" -- idiv raises SIGFPE where LLVM
says nothing, and cvttsd2si answers the integer indefinite value. The
Fn-value question -- body pointer or cell pointer -- is untouched: the
lowering here would have emitted direct calls, which means no
redefinition, and that is a gap to close before this backend is the dev
backend rather than an experiment.
2026-09-13 09:42:50 +07:00
f715be029d Measure what a whole-program x86 build would have to lower
The spike counted nodes it could do. This counts nodes a real input
actually contains, after Reach prunes, which is the question that decides
whether whole-program coverage is reachable.

The answer is worse than the spike's four buckets suggested: enum-compare
needs Str, Make, Field and Call before it prints anything, because the
prelude builds a slice to print one. And loops.flan carries Handled,
RestartCase and Signal one each -- conditions are not an advanced feature
to defer, they are in the reachable set of a program that only loops.
2026-09-13 09:37:30 +07:00
d07edef8e5 loop and recur, and into that fuses a chain 2026-09-13 09:35:49 +07:00
cd0fef35eb A restart is not a transaction, and a watched value shows at its call site 2026-09-13 09:35:43 +07:00
c586eed138 Ghost text finds its anchor in the buffer, not in the table
flan-watch-ghost-mode paints each watched value inline, after the line holding
the call that wrote it. An addition to the watch buffer and not a replacement:
both can be on at once, and turning either off leaves the other running.

The earlier note said ghost text was gated on a (watch ...) form in check.ml,
because nothing in the table carries a source location. That is true of the
table and the conclusion did not follow. The call site is in the buffer, and
the name in the table is the string literal in it, so the anchor is searched
for rather than reported. Nothing new is asked of the daemon. The head of the
call is a defcustom regexp, because watch-i64 is a name the program's author
chose in their own declare-c and only the C symbol behind it is fixed.

Both pictures are painted from one reply in flan-watch--absorb, so they cannot
disagree and there is no second watch request in flight. That meant the watch
buffer could no longer be the subscription: arming and the timer now hang off
flan-watch--consumers, and only the last consumer out disarms the table.

Overlays are replaced wholesale on every repaint rather than followed through
edits, which is the whole answer to invalidating one whose line moved. Only
buffers shown in a window are scanned.

Settled and written down: two sites of one name both show it and say so,
because the table has one slot and the last writer wins; a watch in a loop
shows the last value written, as the buffer does, because every better answer
is the query UI this design exists to avoid; a stopped program's values say
"last frame" and change face, since inline they sit in code that looks live;
a site with no row is annotated only when the table reports overflow.

syntax-ppss moves point and clobbers the match data, so calling it inside a
re-search-forward loop and then reading match-string restarts the scan and the
loop never ends. Everything is read out before the check now.

emacs/test-flan-watch.el covers it, loaded from test-flan-cider.el the way
test-flan-mode.el is, so no build change is needed. 203 checks, 0 failures.
2026-09-13 09:33:16 +07:00
0405666b1f The two cases the tests did not cover, and one leak of a permission
A type-changing (map f) is the case into's single shadowed element name
would break if the shadowing were a trick rather than the language's
rule; it is not, because each stage is a fresh slot at its own type, and
into.flan now runs an i32 source into a (Vec f32) to say so.

A move-only accumulator carried round by recur is the shape BUILT.md
pitches the form on and was untested. It works, and recur.flan now
carries a Vec three times round and answers with it.

block's empty-body arm returned before the loop that distributes the
tail, so (do) in a tail position left ctx.tail set for whatever was
checked next. Latent rather than live — every consumer sets it
immediately before use, and the leaking form is always Unit-typed — but
it is one line to close and the invariant is easier to state closed.

Also the PORTING.md line listing loop/recur among the things with no
customer: it was built, and the half of that finding that still stands is
tail calls, which were not.
2026-09-13 09:32:58 +07:00
7b11dc07c0 The registry answers what is at an address, and a dead pointer says what it was 2026-09-13 09:29:17 +07:00
f795548ef7 The step number in dev-ptr's header is a counter, not a promise
It is the registry's own event clock and moves if anything allocates ahead of
this program's two Vecs. Written as N, with a line saying a test should match
around it rather than on it — a header that reads as a spec and quietly goes
wrong is worse than no header. The fallback socket takes the name every other
dev program in this directory uses.
2026-09-13 09:27:26 +07:00
ca14394e0f into fuses at compile time because it is a macro, not a transducer
(into xs (vec-new i32) (map double) (filter even?)). The function name is
syntax and never a value, so (map double) is (double x) written into the
loop body: no intermediate collection, no closure, no generics, nothing
to inline. Transducers would compose at run time and Rust's iterators
need monomorphisation; a macro needs neither. into.flan counts the pulls,
which is the assertion a unit test cannot make.

The destination is in the form because the destination is the allocation,
and that is what makes spec-memory.md's explicit-allocator rule true by
construction rather than by convention. Which also settles the open
question: reductions do not share the form. A seed is not an allocation,
so (into xs 0 (map cost) (sum)) would be a second form wearing the same
spelling, and the destination would stop being honest about what it is.

A source that is already a name is used as it is, not bound. A (Vec T) is
move-only, so binding it would take the caller's ownership for something
that only reads; a fixed array would be copied once per into. A source
that is anything else is still bound once, which is what a call needs.
The wart is that an owning temporary there leaks, because the binding has
a name the caller cannot reach — a call in that position should borrow,
and drop is what would close it.

All four of the prelude's macro limits bit and none blocked anything. The
three refusals are names nothing defines, which is the only error
facility a macro has. into-wrap is a defn using only special forms, so
Macro.reduce does not drop it, and it is the first thing in the prelude
written as a loop/recur.
2026-09-13 09:27:20 +07:00
c95f11ff18 What the registry answers, and the two places a release build is not free
BUILT.md on the split that is the design — the type is emitted because only the
checker knows it, the death is not because an address needs no type — and on the
part that reads backwards: (Ptr Enemy) already said Enemy, so the registry
supplies permission rather than identification.

The honesty is in the same section rather than a footnote. A release build pays
a load and a not-taken branch per free, because flan_dev.c is in every build and
a second allocator selected by a build flag is worse than a branch. The table is
calloc'd when armed rather than declared, so nothing else is carried. The arena
free-all is answerable now and still invisible to memcheck, and those are two
different claims.

NEXT.md keeps the entry open for what was not built: an op that points at an
arbitrary address, the breakdown by type, leak attribution, the memcheck half,
and the test_dev.ml case that would drive dev-ptr.flan.
2026-09-13 09:25:51 +07:00
f1ebdde7f8 A spike that lowers Tast to x86-64 bytes, and the verdict on doing it for real 2026-09-13 09:24:08 +07:00
6ebcea6d3a The writer's side gets the probe, the reader's side gets the scan
flan_dev_reg_dead was reaching for the containment scan, and it is on the free
path: a dev build would have paid a 4096-entry sweep per free. A free hands back
the base address the allocator gave out, which is what the slot is keyed on, so
the question there is equality and never containment. Only free-all needs the
scan, and that runs once a frame.

The table is allocated when it is armed, not declared. A fixed array was a
quarter of a megabyte of BSS in a shipped game for a table that build never
writes; now a release build carries a null pointer and the not-taken branch.

The pointer arm binds its subject to a slot before naming it three times — the
slice arm's rule, and its reason: an inspect with a path reaches a leaf through
a bounds check, and three of those to render one pointer is the walk paying for
its own shape.

dev-ptr.flan shows both halves on a stopped stack. It was read by hand; the
test_dev.ml case that would drive it is another lane's file, and NEXT.md says so.
2026-09-13 09:24:08 +07:00
6bd353bb8c The aggregate claim, checked against a control instead of asserted
The same three shapes written in C and as first-class IR aggregates,
compiled by the same clang. { i8, i64 } agrees. { i8, float } does not: C
packs both halves into rax, the IR form answers in al and xmm0. And a
24-byte struct does not agree at all -- C spills through an sret pointer,
the IR form returns it in rax, rdx and rcx, and rcx is a register SysV never
uses for a return value.

That resolves the ret-big anomaly the first pass noted and moved past, and
it makes the finding stronger than it was written: the internal convention
is not the C ABI, not only undocumented in the emitted IR.

spike_call0 deleted with it -- declared, never bound, and the two unused
probes were removed for the same reason.
2026-09-13 09:23:36 +07:00
e403e74b90 Feasible, unforgiving, and not the next thing to do
The verdict, as DISCUSS.md item 15. One function goes from Tast to machine
code and answers correctly, so the question is not whether it can be done.

Three findings decide the shape. Layout is already owned -- emit.ml computes
C struct layout for DWARF and is tested against LLVM's own answer -- so the
silent-drift risk item 10 feared most does not arise. The C boundary is the
easy half, because check.ml already rejects aggregates in a declare and the
shim flattens them. And the hard half was not on anyone's list: Flan calling
Flan passes aggregates by value, and LLVM's lowering of a first-class struct
is per-field rather than the C psABI -- { i8, float } comes back in al and
xmm0 where C would pack it into rax, and a %vec return takes a hidden sret
pointer that does not appear in the define line. The internal convention is
an implementation, not a document.

The audit stands on its own: overflow, shifts and evaluation order are
defined; division by zero, INT64_MIN/-1, the float cast, Uninit and
unreachable are not. Uninit is the one that bites, because poison is where
the two backends are supposed to differ.

Unloading: the shadow stack answers the running half and every dev-build
function is on it -- only the slot table is gated, not the frame. It cannot
answer the pointed-into half, which BUILT.md says is the actual reason
nothing is dlclose'd. Escaped function values need a rule the language does
not have.
2026-09-13 09:22:04 +07:00
fedaec3e18 recur is checked, which is the reason to prefer it over tail calls
There is no TCO here and recur is not a cheaper substitute for one: the
compiler verifies the call is in the loop body's tail position, so the
mistake is a compile error where it was written rather than a stack
overflow somewhere else. A loop is a let, a While whose condition is
true, and two jumps — emit.ml is untouched, and the barrier question
recur asks is the one labelled break already answered.

Tail position is a permission that is withdrawn at the top of check, the
same read-and-withdraw defer_ok does, handed back only by a block's last
form, both arms of an if and a match arm. So nothing enumerates the forms
that are not tails, which a pre-pass over the Ast would have had to, and
would have had to keep doing.

loop is also a barrier for break and continue, which is added rather than
inherited: a loop answers with the value of its body and a jump out has
no value to give. That is also why it takes no label. A while inside a
loop keeps its own break.

Two things the shape forced. A loop binding is a plain name, because
destructuring would make recur's argument count unreadable off the
binding vector. And in_loop's "moves a value bound outside the loop"
rule had to be told about the loop's own names, or (loop [v (vec-new
i32)] ...) would have been refused for doing the ordinary thing.
2026-09-13 09:17:56 +07:00
c897526e47 Following a pointer was never a type question; it was a permission question
(Ptr Enemy) already says Enemy, at compile time, in the walk. What the renderer
lacked was any way to know whether the storage at the far end is still there —
and an allocation registry is exactly a record of which addresses it is still
true to read. So the inspector follows a live one and renders the pointee by the
same walk as anything else, and names what died at a dead one.

println does not, and the split is not squeamishness: spec-memory.md fixes what
a printed Ptr prints, a printed line belongs to the program and has to read the
same in a release build, and a release build has no registry to ask. The two
callers already differ in an emitter record; they differ in one more.

No address appears in the text. An address is not stable across two runs, so
printing one would make a rendering depend on where the heap landed — the rule
Render already follows for an allocator. What a reader wants from a dangling
pointer is what died.

registry.flan is one program read twice: a dev build answers for an address at
the heap, arena and pool tiers, and a release build answers 0 to all of it. The
arena row is the free-all Valgrind cannot see — this does not make memcheck
report it, it makes the same read answerable.
2026-09-13 09:17:47 +07:00
c73f05b052 Disassembly on request, and it is a debugging aid rather than evidence
SPIKE_DISASM=1 objdumps the exact buffers that ran. Kept behind a flag and
kept out of the pass/fail path: a disassembly that reads correctly beside a
function answering the wrong number is the normal outcome of hand-encoding.
2026-09-13 09:15:50 +07:00
042b2ce4d0 A restart is not a transaction, and nothing rolls back
If a frame mutates a global and then signals, taking a retry re-runs the
mutation. Control resumes at the restart-case and runs forward; nothing is
undone. Common Lisp has the same property and offers no help either, so this
is written down rather than fixed.

The discipline is that the author chooses where the retry boundary is: a
restart-case above the mutations re-runs them, one below re-runs only what
follows. Put the restart before anything mutates, make the retried section
idempotent, or snapshot what will be re-applied.

It matters more here than in most Lisps because the intended use is a game
loop, and a bad index signalling BoundsError rather than ending the process
made abandoning and retrying a frame an ordinary thing to do.

conditions.org and web/index.html already carried the mechanical half as a
one-line gotcha; those are rewritten in place rather than gaining a second
bullet beside them. spec-conditions.md takes it in section 5, which already
enumerates what a transfer does and does not do. No numbered case changed
meaning.
2026-09-13 09:14:46 +07:00
ec5062a0ed Alignment is a counter, not a property of the prologue
Every stack movement now goes through pushv/popv and increments a depth
word on the function context. A call pads to 16 from wherever the expression
evaluator has left rsp, and asserts the parity before it emits the call.
The nested probe passes.

The stack-argument path is folded into the same counter rather than keeping
its own, because two independent notions of parity is how the bug comes back.
2026-09-13 09:14:44 +07:00
faba8a49f8 The ABI probe catches a real misalignment, which is why it exists
Three synthetic Tast functions calling C: eight integers so two go on the
stack, and a callee that does a 16-byte aligned spill and answers -1 if it
was entered with rsp misaligned. The third calls it from inside a binary
operator.

The third fails. Alignment at a call site is not a property of the prologue
-- it is a property of how much the expression evaluator has pushed, and
the evaluator spills the left operand across the right one's evaluation. A
call in that right operand runs 8 bytes off. Nothing in the arithmetic tests
could see it, because they call nothing that spills a vector register.

This is the raylib failure mode exactly, and it is left red for one commit
so the record shows the probe found it rather than agreeing with the code.
2026-09-13 09:14:09 +07:00
662b25ef5b The note is emitted where the type is known, and dropped where it is not
The checker builds one note after every operation that may have allocated,
because the checker is the only place the concrete element type exists — and it
builds them in every build, because a tree that differed by build flag would
make every pass between here and the backend ask which one it was looking at.
The backend drops them when [dev] is off, before walking the arguments: a note
takes the container's address, and emitting that only to discard the call would
leave an escaped alloca that mem2reg will not promote.

Armed by a global constructor rather than a line in main. A defvar initialiser
can allocate before main runs, and a note that arrived before the flag was set
would be a block the table never heard of.

A dev build reports the live block, answers 1 for a pointer into it, and 0 for
the same pointer after the free. A release build answers 0 to all of it.
2026-09-13 09:13:36 +07:00
0fbca40446 One function goes from Tast to machine code and answers correctly
x86.ml is an instruction selector for the part of Tast that fits in one
integer register: literals, slots, let, if, arithmetic, comparison, and a
call. Everything else raises with the node that defeated it, because an
honest refusal is the measurement and a silently wrong answer would waste
the exercise.

The frontend is the real one -- Reader, Parse, Load, Check -- so what is
lowered is the same Tast.fn the LLVM backend gets. Seven arithmetic results
are compared against what the language says they should be; the disassembly
proves nothing and is not the evidence.

Nothing is wired into the build. No dune file under spike/, driven by hand
with ocamlfind and clang as spike/embed already does.
2026-09-13 09:12:55 +07:00
61c41ed27c Structural typing and tuples are one piece of work 2026-09-13 09:09:29 +07:00
ac31ebc211 An address can answer with a type, because the allocator's caller knew one
The table, and the half of the wiring that needs no type name. A struct is its
C layout with no header and no tag word, so nothing at run time can say what is
at an address — and adding a tag would break the FFI. The registry sidesteps it:
the compiler knows the type at the moment memory is asked for, so the insert is
emitted, and the dead-marking is not, because an address needs no type.

Entries are blocks rather than values and lookup is containment, which is not an
optimisation: every heap pointer a program can hold is interior. (at v i) is
v->ptr + i*size and (resolve p h) is an item in the middle of a pool. Exact hits
would answer nothing anyone can ask.

Dead entries stay until the allocator hands the address out again, which is when
the old answer stops being true. An arena's free-all marks its whole range dead
— the release memcheck is never told about. That does not make memcheck report
it; it makes the inspector able to.
2026-09-13 09:08:38 +07:00
83369196a9 Keep the registry simple; revisit only on evidence 2026-09-13 08:59:27 +07:00
fc6b0f5aba An allocation registry answers what is at an address, without tagging a struct 2026-09-13 08:57:13 +07:00
324d1c6c60 The generated bindings are committed, and the hand-written three stay excluded from them 2026-09-13 08:27:17 +07:00
1504258afb What the header check can actually contradict, said in headers too
BUILT.md and the web page both make the point that the generated half agrees
with the header by construction; headers said "425 declarations" and left a
reader to infer that all 425 are independently verified. It is the file
somebody reads when deciding whether to export the variable at all.
2026-09-13 08:23:30 +07:00
7332bed14a The acceptance case stops asking for a header it no longer needs
The imported-bindings program was skipped without FLAN_RAYLIB_H, which was
right when the bindings only existed if a header was read and is now the one
gate hiding the change. It runs on the same terms as every other raylib case:
libraylib linkable, no raylib-devel. A generated.flan regenerated empty or
stale is now caught on an ordinary machine rather than only on one with a
header exported.

Reach.link already answers the shim worry BUILT.md's cold-build attribution
would otherwise raise: sand.flan links 110 wrappers, not 425, because the
bindings nothing reachable calls are dropped. What is left is 65ms of frontend
on a cold build, against a header read that was 60-90ms of a fresh session and
15.5ms of every redefinition. Both numbers are in BUILT.md now.

Also a swallowed line continuation in the exclusion message.
2026-09-13 08:22:10 +07:00
4789ec0ddb A bad index signals, and the bindings a game's frame path needs are hand-written 2026-09-13 08:17:49 +07:00
4f58093325 A test that fails one run in four was failing that way before today 2026-09-13 08:17:03 +07:00