From 4e89987a83e2c139f499be4030cc9ac695a6e174 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 04:23:35 +0700 Subject: [PATCH] Stop explaining the significance of the sentence just written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forty-odd clauses of the shape "— which is what makes X work" and "that is the point of Y". Each one restates in the abstract what the sentence before it had just said concretely, and a reader who followed the first does not need the second. The facts are unchanged; the examples are untouched. --- web/index.html | 231 +++++++++++++++++++++++-------------------------- 1 file changed, 107 insertions(+), 124 deletions(-) diff --git a/web/index.html b/web/index.html index d99fb28..24477f1 100644 --- a/web/index.html +++ b/web/index.html @@ -239,8 +239,7 @@ something is designed but not built, it says so and gives the message the compil prints for it. Every Flan program on this page is a file in web/examples/ with its output recorded beside it; sh web/examples/check.sh runs them all and compares, and quotes.sh re-derives the blocks that are transcripts -rather than programs. If the page and the compiler disagree, one of those two goes -red.

+rather than programs.

What Flan is

@@ -282,8 +281,7 @@ usage: flan (read|parse|check|emit|shim) <file.flan>...

read, parse, check, emit and shim each stop the pipeline one stage further along and print what it -produced, which is how you find out what the compiler thinks of a form. -run builds to a temporary file and execs it.

+produced. run builds to a temporary file and execs it.

The smallest program:

@@ -317,8 +315,8 @@ slot; reading one is a load, assigning to one is a store, and a store of an aggr borrow checker. -

That last point is what makes a heap unnecessary for a great deal of code: a value -struct is shared mutably by passing its address down the call chain.

+

A value struct is shared mutably by passing its address down the call chain. No +heap is involved.

Places — the forms set accepts — are a fixed list, not an extensible setf:

@@ -349,10 +347,10 @@ wisp 5 1 -

.field and at dereference exactly one pointer level, which -is why (set (.hp p) 8) above is legal when p is a -(Ptr Enemy). Note the last two stores: the whole-object store through -p overwrote e itself, so hp reads 3 and not 8.

+

.field and at dereference exactly one pointer level, so +(set (.hp p) 8) above is legal when p is a +(Ptr Enemy). The whole-object store through p overwrote +e itself, so hp reads 3 and not 8.

Bounds are checked

@@ -375,9 +373,8 @@ $ echo $?

at and slice emit a comparison and a branch to a cold block that names the source location and stops. Checks are on by default and are not -tied to the optimisation level, which is what lets the acceptance table run the same -programs at -O0 and -O2 with identical checks; ---no-bounds-checks turns them off. Measured cost on a +tied to the optimisation level. --no-bounds-checks turns them off. +Measured cost on a 50-million-iteration dependency chain over a 1024-element array: 0.11–0.12s checked against 0.12–0.13s unchecked.

@@ -422,19 +419,18 @@ have one type, and every conversion is written as a cast:

An untyped integer literal is i32 and an untyped float literal is f64, so (defconst gravity f32 0.05) names the type when -something narrower is wanted. One caveat worth knowing before it surprises you: a -whole-numbered float prints without its fraction, so 3.0 comes out as -3.

+something narrower is wanted. One caveat: a whole-numbered float prints without its +fraction, so 3.0 comes out as 3.

Arithmetic wraps. Shifts are bounded two ways: a literal count at or past the -operand's width is a compile error, and a computed one is masked to the width, which -is what the hardware does anyway. >> is arithmetic on a signed type -and logical on an unsigned one.

+operand's width is a compile error, and a computed one is masked to the width. +>> is arithmetic on a signed type and logical on an unsigned +one.

An index converts from a narrower integer and never from a wider one. A u32 index is fine — anything above 231 truncates to a negative i32 and the unsigned bounds check rejects it. An i64 index is -refused, and the message is worth reading because it is the shape of most of them:

+refused:

an index is an i32, and i64 is wider — write (i32 …), because a value that does
 not fit truncates to one that does and would read the wrong element without
@@ -472,10 +468,9 @@ byte is a u8 — but there is a byte literal, so \h is
 (and (>= b \0) (<= b \9)). To see a byte as a letter rather than as a
 number, print a slice of them with print-bytes.

-

An enum is an i32 at run time and its own type in the checker. That is -what makes a keyword at a call site useful: :space resolves against the -parameter's enum type at compile time, and a typo is an error there rather than a wrong -number later.

+

An enum is an i32 at run time and its own type in the checker. A +keyword at a call site resolves against the parameter's enum type at compile time, so a +typo is an error there rather than a wrong number later.

(defenum Key
   [space 32  escape 256  left 263  right 262])
@@ -531,8 +526,8 @@ functions need no forward declaration. Globals come in two kinds:

0

A defconst the checker consumed — an array length, for instance — is -part of the shape of the program. One it did not is only ever bytes in memory, which -matters for reloading; see the dev loop.

+part of the shape of the program. One it did not is only ever bytes in memory. The +two reload differently; see the dev loop.

let binds name/value pairs and takes no type annotation, so a constant whose type matters is named at the top level rather than written inline.

@@ -599,8 +594,7 @@ above.

(Option T) is how absence is spelled: a lookup miss, an empty collection, the end of a stream. match works on an Option and on nothing else today. some unwraps Some and early-returns -None from the enclosing function, which is what keeps a recursive descent -parser readable.

+None from the enclosing function.

(defconst nums [4 i32] [4 8 15 16])
 
@@ -643,8 +637,7 @@ second
 3

defer is function-scoped and is rejected inside a -let, a loop or a branch, rather than accepted with surprising scope. Block -scoping it is real work and is not done:

+let, a loop or a branch. Block scoping it is not done:

defer must be a top-level form in a function body — block-scoped defer is not implemented yet (milestone 4)
@@ -709,28 +702,26 @@ ordinary Flan.

-

Two deliberate choices in there. The RNG is ours, not libc's — -PCG-XSH-RR 32, written in Flan — because a grid hash is only a regression test if the -sequence is byte-identical on native and on wasm32. And the parsers are ours -too: strtoll answers 0 for "", 0 for +

The RNG is ours, not libc's — PCG-XSH-RR 32, written in Flan — +because a grid hash is only a regression test if the sequence is byte-identical on +native and on wasm32. The parsers are ours too: +strtoll answers 0 for "", 0 for "abc" and 12 for "12x", which are three wrong answers a caller cannot tell from a real 12.

-

sqrt-f32 goes the other way, and is the one function in the file that -is not Flan: (declare sqrt-f32 [x f32] f32 "sqrtf"). Every other number +

sqrt-f32 is the one function in the file that is not Flan: +(declare sqrt-f32 [x f32] f32 "sqrtf"). Every other number here is reachable from the four operations and a cast; a square root is not, and the usual trick of seeding Newton's method from the exponent bits needs a bit-cast between f32 and u32 that the language does not have. IEEE-754 makes -sqrt correctly rounded, so libm gives the same bit pattern on both targets -anyway — the very property that keeps the RNG in Flan is, for this one, the argument -for going out to C. It is also why every link carries -lm.

+sqrt correctly rounded, so libm gives the same bit pattern on both +targets anyway. Every link carries -lm.

There is no println. There is no overloading yet, so each printer names -its type. The names are the compiler's answer too: (println 1) is -unknown function println.

+its type. (println 1) is unknown function println.

-

The primitives underneath are few by design, because a primitive is the only thing -that gets implemented twice per backend: argv, +

The primitives underneath are few — a primitive is the only thing implemented +twice per backend: argv, write-stdout, exit, len, at, slice, bytes, bytes->f64, bytes->i64, f64->bytes, i64->bytes, @@ -779,7 +770,7 @@ the importing file until a directory of that name is found.

literal, in an array length — is rewritten to match. Nothing downstream knows a package existed.

-

Three more rules that are easier to know than to discover:

+

Three more rules:

-

signal has type Unit, always. That is the accumulation -case, and it is worth having on its own because it alters no control flow:

+

signal has type Unit, always. A handler that returns +normally leaves the signaller to carry on — the accumulation case:

(defstruct AssetMissing [id i32])
 
@@ -881,10 +872,10 @@ first, before the clause body starts.

Restart lookup walks the dynamic restart stack from innermost outward and takes the first frame offering the name, so an inner restart-case shadows an outer -one for the duration of its body. That is what makes "restarts go at the resync point" -composable.

+one for the duration of its body. An inner parser's skip-form is found +before an outer one's.

-

How it is lowered, and why that matters

+

How a transfer is lowered

A transfer is not platform unwinding. Every Flan signature carries a transfer channel — one pointer appended as an out-parameter — which invoke-restart writes @@ -892,12 +883,11 @@ and every call site checks. A callee writes the target into its caller's slot; e frame checks, runs its defers and returns early. The disassembly is the release one plus a guard after each call.

-

Three consequences to know:

+

Three consequences:

-

From there you fix the function, install it, and take a restart — and because control -never left the erring frame, retry calls through the indirection cell and -reaches the new body. Installing while stopped is allowed: the rule against swapping a -function that is on the stack is about mid-frame consistency, and there is no frame in -progress here.

+

From there you fix the function, install it, and take a restart. Control never left +the erring frame, so retry calls through the indirection cell and reaches +the new body. Installing while stopped is allowed; there is no frame in progress.

The break loop lives in vendor/agent, which is an optional package. A program that does not import it leaves the hook null and stops the old way — the message @@ -993,8 +981,8 @@ of — one line per binding:

[texture Texture2D x i32 y i32 tint Color] "DrawTexture") -

The reason for the wrapper is that an aggregate's calling convention is not -part of its layout. On x86-64, clang gives raylib's own prototypes +

An aggregate's calling convention is not part of its layout. On +x86-64, clang gives raylib's own prototypes <2 x float> for a returned Vector2, i32 for a Color argument, and { i64, i64 } for a returned Rectangle — none of which is the struct's own LLVM type, and arm64 and @@ -1026,8 +1014,8 @@ void flan_shim_get_mouse_position_5ad0e205(flan_ty_Vector2_1bebc5ae *out) { }

No library header is read, deliberately, so a build needs the shared library to be -linkable and not the -devel package to be installed. What follows from that -is what the generator can and cannot promise. Guaranteed: the C typedef +linkable and not the -devel package to be installed. +Guaranteed: the C typedef and the Flan struct come from the same defstruct, so they cannot disagree, and clang type-checks the wrapper against the generated prototype. Trusted: that the defstruct matches the library's real @@ -1048,7 +1036,7 @@ existing binding is unaffected.

The dev loop

-

This is the thesis of the project: edit the code, keep the sand.

+

Edit the code, keep the sand.

$ flan dev sand.flan
@@ -1059,7 +1047,7 @@ frame boundary. The window does not blink and the grid does not reset.

How it works

-

Four pieces, each of which can be run on its own.

+

Four pieces, each runnable on its own.

The reload primitive. llcld -shareddlopen → call. Measured in this codebase:

@@ -1074,9 +1062,9 @@ frame boundary. The window does not blink and the grid does not reset.

-

About 19ms end to end. The clang driver on the same IR is 50ms, which is -why the dev path never invokes it: the driver forks a second process and re-does -argument and target resolution, and codegen is not the cost.

+

About 19ms end to end. The clang driver on the same IR is 50ms, so the +dev path never invokes it. The driver forks a second process and re-does argument and +target resolution; codegen is not the cost.

Indirection cells. Loading a new body is not installing it. A call bound at link time cannot be made to notice one, so a --dev build routes @@ -1084,7 +1072,7 @@ every Flan-to-Flan call through a cell — a mutable global holding the address function that is current.

Here is the whole of hello.flan through -flan emit --dev, which is the shortest thing that shows it:

+flan emit --dev:

@"flan.cell.print-line" = global ptr @"flan.print-line"
 
@@ -1093,20 +1081,19 @@ entry:
   %t1 = load ptr, ptr @"flan.cell.print-line"
   %t2 = call {} %t1(%slice { ptr @".str.36", i64 15 }, ptr %xfer)
-

Two things are visible there at once. The call site loads the cell rather than naming -@"flan.print-line" directly, and the signature carries ptr %xfer -— the transfer channel from conditions, which every Flan -function has, release builds included.

+

The call site loads the cell rather than naming @"flan.print-line" +directly. The signature carries ptr %xfer — the transfer channel from +conditions, on every Flan function, release builds +included.

-

Redefinition is then one store, below a microsecond, which is what makes a -frame-boundary swap a non-event. Three rules fall out of it. A redefinition module -declares every global external, so globals live in the host and survive a reload -— that is what "keep the sand" means. Every other function is a declare, so -a redefined settle calls the host's move-grain rather than -freezing a private copy of it. And nothing is ever dlclosed: a cell holds an -address inside a module's text, so unloading it would leave call sites pointing at -unmapped memory. Old code is never unloaded, which is also why a thread mid-execution -finishes safely in the old version.

+

Redefinition is then one store, below a microsecond. Three rules follow. A +redefinition module declares every global external, so globals live in the host +and survive a reload. Every other function is a declare, so a redefined +settle calls the host's move-grain rather than freezing a +private copy of it. And nothing is ever dlclosed: a cell holds an address +inside a module's text, so unloading it would leave call sites pointing at unmapped +memory. Old code is never unloaded, so a thread mid-execution finishes safely in the old +version.

The agent. vendor/agent is a package like any other: a listener thread, a single-producer ring, and three calls. This is the whole of @@ -1123,11 +1110,10 @@ listener thread, a single-producer ring, and three calls. This is the whole of

(agent/start path) listens on a unix socket, once, at startup. (agent/poll) installs whatever has arrived and returns how many. -(agent/wait ms) is the same but waits for something first, which is what a -headless test uses so that a reload is deterministic rather than a race against the -frame rate.

+(agent/wait ms) is the same but waits for something first. A headless test +uses it so that a reload is deterministic rather than a race against the frame rate.

-

The split between loading and installing is the design. dlopen relocates +

Loading and installing are separate. dlopen relocates a module and takes the loader lock — milliseconds, unbounded — so it happens on the listener thread. Installing is one store per function and must not land while a redefined function is on the stack, so it happens on the game thread, at the top of the frame, when @@ -1136,10 +1122,10 @@ ignores the result.

The session and the daemon. flan dev holds the declarations the running process was built from plus every change accepted since, and it -owns the build — which is what makes its rules describe the process that is actually -running rather than a guess about it. Re-checking the whole program on every evaluation -costs under 10ms, less than the llc that follows, and it makes an evaluation -transactional for free: a form that fails to check mutates nothing.

+owns the build, so its rules describe the process that is actually running. Re-checking +the whole program on every evaluation costs under 10ms, less than the llc +that follows. An evaluation is transactional: a form that fails to check mutates +nothing.

The protocol is one s-expression per message, length-framed by a decimal byte count. It is not nREPL: eval there is string-in/string-out with no slot for @@ -1163,23 +1149,23 @@ a silent mismatch against memory the process has already laid out:

A defvar's initial value is deliberately not on that list: -refusing to change it would be refusing the whole point. And a defconst the -checker never consumed can be changed, which is how a colour table gets tuned live while -an array length stays refused — a dev build emits those as mutable globals so LLVM cannot +its storage holds state the program moved past long ago. And a defconst +the checker never consumed can be changed, so a colour table can be tuned live while an +array length stays refused. A dev build emits those as mutable globals, so LLVM cannot fold a read of one.

-

The signature row is a stopgap and the message should not be read as the final answer. -The design is versioned functions with their own trampolines, so that new callers resolve -the new version while existing ones keep the old; none of the three parts exists yet, and -the alternative to refusing is not the new design, it is a silent argument mismatch.

+

The signature row is a stopgap. The design is versioned functions with their own +trampolines, so that new callers resolve the new version while existing ones keep the +old. None of the three parts exists yet, and the alternative to refusing is a silent +argument mismatch.

Evaluating an expression

C-x C-e is a different primitive from redefining a name. There is no name to install a body into, so the expression is wrapped in a thunk with nowhere to be called -from; the module says run this once, and the agent calls it after the install, on -the game thread, at a frame boundary — so an expression reading the program's state sees -a point the program agrees is consistent.

+from. The module says run this once, and the agent calls it after the install, on +the game thread, at a frame boundary. An expression reading the program's state therefore +sees a consistent one.

Nothing is marshalled back, because nothing could be: a Flan value carries no header, so no code at run time can say what it is. The compiler knows the type and renders it @@ -1193,15 +1179,14 @@ b (Blob {:id 7 :name "sandy \"quoted\"" :pos (V {:x 1.5 (rl/get-color 0x11223344) (rl/Color {:r 17 :g 34 :b 51 :a 68}) sim/grid [ [ 0 0 0 0 0 0 0 0 ...] [ 0 ... ] ...] -

A pointer is never followed — it renders as <ptr> — because it is -the only thing that could make the walk cycle, and dereferencing one a REPL was handed is -not a safe thing to do on someone's behalf. The walk is bounded at depth 4 and 8 elements, -and the output truncates at 4K. Map, function values and type variables -refuse by name.

+

A pointer is never followed; it renders as <ptr>. Following one +would make the walk cycle, and dereferencing a pointer a REPL was handed is not safe. +The walk is bounded at depth 4 and 8 elements, and the output truncates at 4K. +Map, function values and type variables refuse by name.

-

The thunk's module is unloaded afterwards, which is the one case where that is safe: -nothing points into its text once it has returned. Sixteen expression evaluations retain -zero mappings, where each redefinition retains three, permanently and correctly.

+

The thunk's module is unloaded afterwards. Nothing points into its text once it has +returned. Sixteen expression evaluations retain zero mappings, where each redefinition +retains three, permanently and correctly.

Emacs

@@ -1210,7 +1195,7 @@ zero mappings, where each redefinition retains three, permanently and correctly. already right. It adds Flan's brackets — [ and { are brackets, not symbol characters, since every binding list and every type is written with them — and the characters a Flan name may contain. emacs/flan-dev.el is the client; -there is no parser in it, which is the point of the protocol choice.

+there is no parser in it.

@@ -1257,17 +1242,17 @@ $ flan build test/programs/sand-headless.flan --target=wasm32-wasi -o sand.wasm $ node --no-warnings test/wasm-run.mjs sand.wasm -2851001042534928384 -

That number is the whole point of writing the RNG in Flan rather than calling libc's: -a grid hash is only a regression test if the sequence is byte-identical on both targets. -It holds at -O2 and at -O0. The wasm side needs a +

The RNG is written in Flan rather than called from libc for that number: a grid hash +is only a regression test if the sequence is byte-identical on both targets. It holds at +-O2 and at -O0. The wasm side needs a wasm32 builtins archive — from wasi-sdk, or emscripten's substituting for it — and the compiler names every path it looked in when it cannot find one.

Dev and release builds are deliberately different. --dev means -indirection cells and -rdynamic, which is what exports the cells for a loaded -module to bind to; release builds call directly, emit constants as constants, and get all -the folding back. Dev builds are not pruned by reachability, because what a REPL may -redefine next is not a function of what has been called so far.

+indirection cells and -rdynamic, which exports those cells for a loaded +module to bind to. Release builds call directly, emit constants as constants, and get all +the folding back. Dev builds are not pruned by reachability: what a REPL may redefine +next is not a function of what has been called so far.

--debug is a third flag beside --dev and the optimisation level. --dev asks whether you can redefine the program while it runs; @@ -1293,10 +1278,9 @@ target flags, so it never needs invalidating by hand.

Not implemented yet

-

The house rule is that anything which binds a name, alters control flow, or is not yet -implemented must be recognised explicitly and rejected. So these are not missing features -you discover as a strange type error — each refuses by name, with the milestone it belongs -to, and the tests assert on the reason.

+

The house rule is that anything which binds a name, alters control flow, or is not +yet implemented must be recognised explicitly and rejected. Each of these refuses by +name, with the milestone it belongs to, and the tests assert on the reason.

@@ -1330,9 +1314,8 @@ managed class facility that plan.org describes is a plan and not a put, resolve and allocator-aware operations belong to Vec and Map, and arrive with them.

-

Two notes on what is settled, because their absence reads like an oversight. -There is no interpreter and there is not going to be one: the compiled -path is the only backend. The instrumentation-based step debugger that wanted one is cut, +

Two of these are settled rather than pending. There is no interpreter +and there is not going to be one: the compiled path is the only backend. The instrumentation-based step debugger that wanted one is cut, and compiled redefinition at ~19ms is perceptually instant for expression evaluation too. And the macro expander is blocked on unions rather than on itself — a macro is a function from Form to Form, which needs