There is no interpreter, and the plan stops promising one

The Compilation section was written around a permanent tree-walking backend for
expression eval. Open decision #7 closed the other way and BUILT.md records it:
compiling is the only way a form is ever run. The diagram, the milestone-2 exit
criterion, milestone 7's "free in the interpreter", the dev/release table and
the decision itself all said otherwise, and lib/expand.ml states the settled
answer at the top of the file.

Also here, because the same section was the place they were missing: the
hand-written x86-64 code generator, which is a second route from the typed IR to
the same observable behaviour rather than a second semantics; DWARF from both
code generators rather than from LLVM alone; OCaml as a settled host language;
and the map-new example, which has named its key and value types since braces
stopped being a type spelling.
This commit is contained in:
Joseph Ferano 2026-09-14 07:27:36 +07:00
parent be39f32cb6
commit 00163bcf34

158
plan.org
View File

@ -101,7 +101,7 @@ world.
- Map keys initially use compiler-provided structural equality and hashing for
integers, enums, strings, fixed arrays and value structs; pointers, slices and
owning containers are excluded. A map is homogeneous, and empty construction
is type-directed: ~(defvar enemies (Map string Enemy) (map-new))~. ~get~
names its types: ~(let [enemies (map-new string Enemy)] ...)~. ~get~
returns ~(Option V)~; ~put~ is the ~()~-returning upsert. See
spec-memory.md for the deferred move-aware operations.
- Operations: ~get~, ~put~, ~remove~, ~push~, ~pop~, ~at~, ~len~, ~update~.
@ -401,8 +401,8 @@ primitives, and are never bootstrapped away.
The LLVM question does not bear on this: the release backend emits LLVM IR *as
text* and shells out to ~clang~, so no language needs LLVM bindings, and C++ or
Rust buy nothing here. What the choice actually turns on is that milestones 25
are a reader, a typed IR, a checker and a tree-walking interpreter — variants and
exhaustive pattern matching, which is the one domain where OCaml is not a
are a reader, a typed IR, a checker and the code generators behind it — variants
and exhaustive pattern matching, which is the one domain where OCaml is not a
preference but a clear win. There is also a menhir lexer/parser already started
in ~old-ocaml/~.
@ -415,7 +415,7 @@ build sequence. For a game language it buys dogfooding at the price of a second
compiler to maintain forever. Choose as if the host language is permanent.
* Milestone-2 primitives
The interpreter provides these; everything else is written in Flan. Keeping the
The runtime provides these; everything else is written in Flan. Keeping the
list short is the whole strategy — it is what makes the LLVM backend and the
wasm32 target cheap, because a primitive is the only thing implemented twice.
@ -477,33 +477,54 @@ is Clojure's ~ns~ form: no path that must mirror the directory, no
root-directory aliases.
* Compilation
*Two backends and three paths.* The split is not dev-vs-release; it is
/does this code have a frame budget/.
*One evaluator and three paths.* The split is not dev-vs-release; it is
/does this code have a frame budget/. There is no interpreter: open decision #7
is settled the other way from how this section was first written, and BUILT.md's
"Why there is no interpreter" carries the reasoning. Compiling is the only way a
form is ever run, so there is no second evaluator that could disagree with the
first about what a program means.
#+begin_src
expression eval: flan → typed IR → interpreter ~1ms
dev redefinition: flan → typed IR → .ll → llc → ld -shared → dlopen → cell store
~16ms (MEASURED)
expression eval: flan → typed IR → .ll → llc → ld -shared → dlopen → call
~19ms (MEASURED)
dev redefinition: the same path, ending in a cell store rather than a call
release build: flan → typed IR → .ll → clang --target={native,wasm32}
#+end_src
*Hard requirement: eval is immediate.* Not "fast enough for a build" — immediate,
because the whole point of the live loop is that you see the result. 16ms is one
frame at 60fps and under the ~50ms threshold where a response stops feeling
instantaneous. The rule that buys it: *never invoke the ~clang~ driver on the dev
path.*
because the whole point of the live loop is that you see the result. 19ms is
around one frame at 60fps and under the ~50ms threshold where a response stops
feeling instantaneous. The rule that buys it: *never invoke the ~clang~ driver on
the dev path.*
*Expression eval*~C-c C-e~, calling a function, inspecting a var, running a
test — goes to the tree-walking interpreter. Sub-millisecond, no subprocess. This
is the permanent REPL backend, not a milestone-2 scaffold.
*Expression eval*~C-x C-e~, calling a function, inspecting a var, running a
test — is compiled like everything else, into its own shared object, which is
then loaded and called. What made an interpreter look necessary was the
assumption that this had to be sub-millisecond; the measurement below is that
the compiled route is already inside the threshold, and the one thing an
interpreter would have bought is an oracle the hand-written acceptance table
supplies instead.
*Dev redefinition*~C-c C-c~ on a function inside a running game — cannot use
the interpreter, because that code has an 8ms frame budget. It recompiles the one
function, links it, and does the atomic indirection-cell store. This is what the
Hot reload section has always described; the interpreter does not replace it.
*Dev redefinition*~C-c C-c~ on a function inside a running game — has an 8ms
frame budget to respect. It recompiles the one function, links it, and does the
atomic indirection-cell store. This is what the Hot reload section has always
described, and it is the same machinery expression eval uses, one step further
on.
*Release* is whole-program AOT with direct calls and no cells.
*A second code generator, not a second evaluator.* ~lib/x86.ml~ emits x86-64
machine code directly and is selected with ~--x86~; it exists because ~llc~ is
most of the 19ms above. It is a different route from the same typed IR to the
same observable behaviour, not a different semantics, and what holds it to that
is ~spike/x86/survey.sh~: every program in the corpus is built both ways and
byte-compared on stdout, stderr and exit status. At the time of writing that is
103 MATCH, 0 DIFFER, 0 refused by name. It handles conditions, bounds checks,
indirection cells, redefinition modules and DWARF line tables; what it does not
have, and must not grow, is an aggregate classifier — an ~--x86~ host therefore
takes ~--x86~ modules and an LLVM host takes LLVM ones, and ~lib/build.ml~
refuses the crossed pair by name.
** Measured redefinition latency
Single function, x86-64, clang 20.1.8, 20 iterations each:
@ -563,9 +584,10 @@ This is why *the dev runtime is multithreaded* — it needs the reload thread. T
is settled, and is independent of whether the /language/ exposes threads, which
is still open decision #4.
It also bears on whether the interpreter survives: if the agent can ~dlopen~ and
call anything in 16ms, then even "eval this expression against live game state"
can be a compiled ~.so~, and no interpreter is needed inside the game process.
This is also what settled the interpreter question: if the agent can ~dlopen~ and
call anything in under 20ms, then even "eval this expression against live game
state" is a compiled ~.so~, and no interpreter is needed inside the game process.
That is the route ~C-x C-e~ actually takes.
** Why LLVM IR as text
| | text ~.ll~ → ~clang~ | libLLVM bindings | emit C |
@ -580,32 +602,36 @@ The only column text loses is the JIT one, and the measurement above shows the
loss is ~13ms — below perception. ORC remains addable later behind the same typed
IR without touching the language, but nothing currently argues for it.
** The interpreter cannot run sand
Do not plan around it. 200 × 280 = 56,000 cells, scanned by ~game-update~ and
again by ~game-draw~ — ~112,000 interpreted cell-visits per frame against an
8.3ms budget at 120fps. At an optimistic 100ns per visit (environment
allocation, argument binding, two index computations, a compare) that is 11ms
before ~settle~, ~paint~, or a single raylib call. Expect 2030fps.
** The interpreter, and why there is not one
An interpreter could never have run sand, and that was the first half of the
argument. 200 × 280 = 56,000 cells, scanned by ~game-update~ and again by
~game-draw~ — ~112,000 interpreted cell-visits per frame against an 8.3ms budget
at 120fps. At an optimistic 100ns per visit (environment allocation, argument
binding, two index computations, a compare) that is 11ms before ~settle~,
~paint~, or a single raylib call. Expect 2030fps. Milestone 4's interactive
acceptance test was always going to run on the compiled dev build.
This is an estimate, not a measurement, which is why *milestone 2 exits with a
measured interpreter throughput number* — before milestone 4 depends on it.
Milestone 4's interactive acceptance test runs on the compiled dev build; the
interpreter is not in that loop.
*** Settled: the compiled path is the only backend
This was open decision #7 and it is closed. Compiled redefinition measured at
~19ms is perceptually instant for expression eval too, so the one thing an
interpreter was still wanted for went away; the instrumentation-based step
debugger that wanted it is cut (see Tooling); and milestone 3 did not need it as
an oracle either, because the acceptance table is hand-written and the table /is/
the oracle. What is bought by dropping it is the standing obligation: two
evaluators must agree on observable behaviour forever, and every divergence is a
bug that reproduces in only one of them. BUILT.md's "Why there is no interpreter"
records the decision; ~lib/expand.ml~ states it at the top of the file, because
macros are where the absence stopped being free — a macro has to run at compile
time and there is nothing to interpret it with, so the compiler compiles it into
a shared object and loads it with ~dlopen~ into its own process.
*** Open: does the interpreter survive milestone 3?
Now that compiled redefinition is measured at 16ms, the case for a /permanent/
interpreter is weaker than it looked. 16ms is perceptually instant for expression
eval too, and one backend removes a standing obligation — two backends must agree
on observable behaviour forever, and every divergence is a bug that reproduces in
only one of them.
Consequences applied elsewhere in this document: milestone 2's "interpreted calls
per second" exit criterion is dropped, and the host ABI moved onto the critical
path in its place.
Against dropping it: the interpreter is clearly right for milestone 2 (far less
work than an LLVM backend, better error messages, no linking), and the
instrumentation-based step debugger wants it. Decide at milestone 3 exit on
measured numbers, not now.
All three paths share the frontend and the typed IR and must agree on observable
behaviour. That agreement is what the acceptance programs test.
The paths that remain share the frontend and the typed IR and must agree on
observable behaviour. That agreement is what the acceptance programs test, and
for the two code generators it is tested byte for byte.
- Non-local exit lowered *explicitly* (result propagation + branch targets), not
via platform unwinding. Same on both targets, no dependency on the WASM
@ -635,7 +661,7 @@ Deliberately different.
| | Dev | Release |
|---------+---------------------------+------------|
| Backend | interpreter /and/ LLVM | LLVM/clang |
| Backend | LLVM, or ~--x86~ | LLVM/clang |
| Calls | indirection cells | direct |
| Code | never freed | static |
| Frames | shadow stack | none |
@ -698,8 +724,11 @@ trap handling, frame unwinding), duplicating an enormous existing project.
Neither covers the other's column, so this is not a choice between them.
*DAP is nearly free.* No debug adapter is written: emit DWARF from the LLVM
backend and point ~lldb-dap~ at the binary; dape speaks to that. lldb and gdb
*DAP is nearly free.* No debug adapter is written: emit DWARF from the backend
and point ~lldb-dap~ at the binary; dape speaks to that. Both code generators do
— the hand-written one writes its compile unit, subprograms and line table out as
bytes, since ~.loc~ cannot work against a file whose instructions are ~.byte~
blobs, and what it does not describe is locals and types. lldb and gdb
both ship DAP interfaces already.
This is where /no object headers/ pays off a second time. Flan structs *are* C
@ -756,17 +785,21 @@ building the whole live environment at once.
1. *Freeze the model.* spec-memory.md and spec-conditions.md — done before any
code. Fixed arrays, non-owning slices, move-only ~Vec~/~Map~, allocators,
~Ptr~, explicit ~clone~; the six restart cases. /Done./
2. *Run calc-me.flan on the interpreter.* Reader, typed IR, checker,
tree-walking backend. /Exit criterion includes a measured throughput number/
— interpreted calls per second on a tight loop — because milestone 4's frame
budget depends on it (see Compilation). Packages, structs, ~(Ptr T)~ + ~addr~, byte slices,
2. *Run calc-me.flan.* Reader, typed IR, checker, and a backend that can carry
the program end to end. The exit criterion was once a measured interpreter
throughput number; with no interpreter that criterion is gone and the narrow
host ABI took its place on the critical path (see Compilation). Packages,
structs, ~(Ptr T)~ + ~addr~, byte slices,
~at~/~len~, ~while~, ~set~ on the fixed place list, ~cond~, ~match~, ~Option~
+ ~some~, ~i32~/~u8~/~f64~, recursion, argv, stdout. No allocator, no ~Vec~,
no generics, no user macros, no FFI, no window. Headless, so the acceptance
test is a table of expression/result pairs.
3. *Emit LLVM IR and pass the same calc-me test AOT*, on native and wasm32 in CI.
Both backends, one test table, one narrow host ABI (argv, stdout, exit). This
Both targets, one test table, one narrow host ABI (argv, stdout, exit). This
is where the second target gets proven — while there is almost nothing to port.
The hand-written x86-64 code generator is not on this path: it arrived later,
as a second route to the same behaviour rather than a milestone of its own,
and is held to the LLVM backend's output byte for byte (see Compilation).
4. *Run sand.flan.* Fixed 2-D arrays, ~dotimes~, ~defer~, and typed FFI to
raylib including keyword→enum coercion. Acceptance test twice: headless (N
frames, hash the grid — runnable in CI on both targets) and interactive at
@ -776,8 +809,8 @@ building the whole live environment at once.
special forms in the compiler.
6. *Allocators, ~Vec~/~Map~, ~Result~/~try~/~errdefer~, then conditions and
restarts* against spec-conditions.md, with dedicated tests per numbered case.
7. *Hot reload*free in the interpreter, indirection cells for compiled dev
builds, with signature generations and stale-caller warnings, plus the
7. *Hot reload*indirection cells in dev builds, with signature generations
and stale-caller warnings, plus the
remaining compatibility limits written down and enforced: struct layout
changes, live callbacks held by C, captured environments.
8. *Debugger, nREPL, async* — last, and 8 splits into transport (8a) and editor
@ -823,8 +856,8 @@ monomorphisation, no restarts and no reload.
None of these block milestone 2. The milestone each one must be answered by is
marked.
1. *Host language: OCaml or Rust*the only thing blocking the scaffold. See
Host language. /Milestone 2./
1. *Host language: OCaml or Rust*/settled: OCaml,/ and the compiler has been
written in it since. See Host language for what the choice turned on.
2. Macro hygiene is settled for milestone 5: explicit ~gensym~, deliberately
non-hygienic expansion, no local macros until a concrete use case appears.
3. Borrow checking and escaping frame-arena values. /Deferred; revisit after
@ -855,7 +888,8 @@ marked.
a ~defvar~. Each needs an answer of the form "rejected", "accepted with a
migration", or "accepted and the old code keeps running".
7. Does the interpreter survive milestone 3, or is the compiled path the only
backend? /Milestone 3, on measured numbers./ See Compilation.
backend? /Settled: the compiled path is the only one, and there is no
interpreter./ See Compilation, and BUILT.md's "Why there is no interpreter".
8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the
compiler niche-optimises ~(Option (Ptr T))~ to a nullable pointer. The
CL-vs-Clojure truthiness question is moot under static typing.
@ -875,8 +909,10 @@ marked.
- Dev redefinition latency → ~16ms, measured: ~llc~ + ~ld -shared~ + ~dlopen~,
never the ~clang~ driver, ~dlopen~ off the game thread, cells published in a
batch at a frame boundary. See Compilation.
- Dev backend → interpreter for milestone 2 certainly. Whether it /survives/
milestone 3 is open, not settled — see Compilation.
- Dev backend → compiled, and only compiled. The interpreter that milestone 2
was going to be written against was never needed and does not exist: expression
eval is a compiled shared object like everything else, and a macro is the case
that made the absence load-bearing rather than merely tidy. See Compilation.
- ~set~ on places → a fixed list of assignable forms, not ~setf~.
- Loop story → imperative ~while~/~until~/~dotimes~ with ~break~/~continue~ and
~return~; ~loop~/~recur~ only if it later earns its place. It did: both are