Compare commits

..

12 Commits

Author SHA1 Message Date
0251cf4aaa Both comments predated the marker, and now neither does 2026-09-14 07:57:41 +07:00
64e4f6cc7e The last opt-in claim, the macroexpand op, and two quoted programs that match their files
NEXT.md contradicted itself about the raylib header: two entries said the check
runs on every build and a third, further down, still explained why it is opt-in.
It gets the strikethrough its siblings have, with the reason the argument
dissolved -- the commit, not a change of mind about the property.

plan.org's list of what the dev runtime answers over nREPL had gained a member it
never listed. And the page quotes a program so that the line numbers in the output
beside it resolve, so both new examples are now pasted from their files rather than
trimmed, and cast.flan gets the source block every other example has.
2026-09-14 07:52:35 +07:00
8bd5547ddc The generator's own output, quoted with the number it prints 2026-09-14 07:49:00 +07:00
acda9edd7c The reference page: a stale recorded output, a refusal that no longer happens, and the condition it never mentioned
web/examples/check.sh was failing, and had been since 2026-09-12. printing.out
records the structural printer's output with colon field labels; the printer emits
dots. The language is right and the recording is stale -- the colon-to-dot sweep
worked on Flan source forms and never touched a .out file, and check.sh is not
part of dune test, so nothing has run it since the day both landed. The acceptance
suite settles which side is correct: it expects the dot spelling everywhere and is
green. The page quoted the same stale line.

The x86 paragraph told a reader that conditions are the visible gap and quoted a
build failing by name. That exact command now succeeds and produces a binary:
conditions, guards, cells, redefinition modules and DWARF all landed, the survey
is 103 MATCH / 0 DIFFER / 0 refused, and what is still refused is narrow enough to
name -- an aggregate crossing the C boundary, which is the classifier this backend
exists not to have.

ArithError was absent from the page entirely. It sits beside the bounds section
because it is the same decision twice, including the part where no restart is
established at the failing operation. Its two programs are in web/examples/ and
checked, which is the page's own rule for anything it quotes.
2026-09-14 07:43:52 +07:00
1a6ea7ae69 Citations follow the move into docs/ 2026-09-14 07:38:23 +07:00
5a4f12bacd Three type-constructor comments spell the map type the way the parser does 2026-09-14 07:36:24 +07:00
f47f9ffe59 Two paragraphs in BUILT.md superseded in place, and the spike report says it is one
BUILT.md appends and never dates, so a paragraph that was true when it was
written reads as fact forever. Two are not: the map type spelling is (Map K V)
and braces in type position are refused by name, and sand.flan has not called
load-texture since it was cut back to port parity. Both get the parenthetical
the FLAN_RAYLIB_H paragraph already got rather than an edit, since that is the
convention the file has.

SPIKE-GENERICS.md gets the treatment overview.md has: a header saying what it is
and when it stopped being current. Its body stays. The two things in it that
would now mislead are named there -- its account of what plan.org says, which
plan.org has since overtaken, and the bare-t-at-every-use rule, which is narrower
than what shipped, since (Option t) does not compile.
2026-09-14 07:36:24 +07:00
dcba88ffd6 The 09-13 handoff says it is a snapshot, and two settled questions stop reading as open
NEXT.md prepends, so its second section still opened with "Read this first" while
pinning a tree six lanes behind and listing as live a queue most of which has
landed. It keeps its contents; what it gains is the sentence saying when it
stopped being where the tree is, and the survey count it quotes measured again on
this tree -- 103 MATCH, 0 DIFFER, 38 skipped.

The two generics entries get the file's own strikethrough treatment, which their
siblings already had: the {K V} catch resolved exactly as it predicted it would,
and there are five predicates rather than four.

Also here: conditions.org gains ArithError beside BoundsError, with the split
between the two runtime conditions that offer retry and the two that deliberately
offer nothing; and tast.ml's header stops naming a tree-walking interpreter among
the backends that consume the typed IR.
2026-09-14 07:36:24 +07:00
0aebef62f3 The sketch's condition syntax, checked against the parser rather than recalled
handler-bind takes (Type [name] body ...) and has since it was written; the
sketch paired a type with an fn, which is the shape parse.ml names in its own
refusal message. load-level had two return types. And there is no defcondition
anywhere in the tree -- a condition type is an ordinary struct, which is what
both spec-conditions.md and conditions.org say, so the one form in this file that
introduced one was inventing it.

The header's rules went with them: lowercase-is-a-type-variable and "no sigils"
are both the pre-$t spelling, and let never took an annotation.
2026-09-14 07:36:24 +07:00
d94e864dba The syntax sketch shows syntax that compiles, and says where it does not
Every generic example in syntax-sketch.flan was written in the lowercase-variable
spelling that $t replaced, so the file whose job is to be copied from was the one
place the wrong spelling cost the most. Checking the replacements against the
compiler rather than against the sketch turned up three more, and each is now
either fixed or named:

- reduce is (reduce s init f) and its accumulator is the element type. The old
  argument order was Clojure's and the fold in centroid is not a shape it has.
- an fn cannot be written inline into a (Fn [$t $t] bool) parameter, because a
  generic body is checked with nothing substituted and the fn has no types to
  take. Inside a generic the callback is a named defn.
- (largest hps >) does not work and never did: an operator is not a name. Both
  this file and spec-memory.md asserted it.

centroid is left as it was, because component-wise arithmetic over a fixed array
is planned rather than built and an aspirational example is not a defect -- but
it now says so instead of leaving it to be found.
2026-09-14 07:36:24 +07:00
daee402026 The two specs catch up with generics and with the conditions the runtime signals
spec-memory.md's generics section still said there were no constraints and wrote
its type variable as a bare lowercase name, which is the spelling the $t sigil
replaced -- the largest example in it would not have compiled. The where clause
and the five predicates are plan.org's account and this now agrees with it rather
than contradicting it. The empty-map example named no types and used defvar,
neither of which is how map-new is called.

spec-conditions.md named BoundsError once and nothing else. The four conditions
the runtime signals, and the split between the two that establish a retry and the
two that deliberately establish nothing, belong in \xc2\xa75 because that is the section
whose account of restarts the split could have falsified. It does not: the rule is
that the restart a bad index wants is the one the program already had.
2026-09-14 07:36:24 +07:00
00163bcf34 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.
2026-09-14 07:36:24 +07:00
19 changed files with 425 additions and 147 deletions

31
NEXT.md
View File

@ -18,8 +18,17 @@ IEEE already answers it.
# Where this is — end of 2026-09-13, second handoff # Where this is — end of 2026-09-13, second handoff
**This section is the record of that evening and is no longer where the tree is.** It is kept because the
reasoning in it is still the reasoning, but read the section above this one first, and take the state below
as a snapshot: it pins `dev-loop` at `e725a5a`, and since then the arithmetic-condition lane, the
redefinition emitter, the aggregate case across the reload boundary, the two unreached guards, DWARF for
`--x86`, the cost measurements, the last two raylib ports and the `dune test` noise have all landed. The
x86 survey it quotes at 97 now reports **103 MATCH, 0 DIFFER, 0 refused by name, 38 skipped**, and of the
seven remaining items it lists from `docs/handoffs/HANDOFF-x86-rt.md`, items 1, 3, 4, 5, 6 and 7 are done —
each has a `docs/handoffs/HANDOFF-x86-*.md` of its own.
**Branch `dev-loop` at `e725a5a`, working tree clean, `dune test` green, every lane merged.** **Branch `dev-loop` at `e725a5a`, working tree clean, `dune test` green, every lane merged.**
Nothing is running and nothing is half-built. Read this first. Nothing is running and nothing is half-built.
## The x86 backend is correct, and is not yet the dev backend ## The x86 backend is correct, and is not yet the dev backend
@ -466,13 +475,14 @@ clause tells the abstract pass what it may assume, so the body checks at the def
- The clause is written as a **Clojure-style map at the head of the body**, `{:where (ordered? $t)}`, chosen by - The clause is written as a **Clojure-style map at the head of the body**, `{:where (ordered? $t)}`, chosen by
the author over a bare keyword. It disambiguates because a bare `{}` in expression position is already refused the author over a bare keyword. It disambiguates because a bare `{}` in expression position is already refused
(`parse.ml:110`), so a `{}` there can be nothing else, and Clojure's `{:pre [...] :post [...]}` is the (`parse.ml:110`), so a `{}` there can be nothing else, and Clojure's `{:pre [...] :post [...]}` is the
precedent. It leaves room for further keys without new syntax. **One catch to settle first:** `{K V}` is precedent. It leaves room for further keys without new syntax. ~~**One catch to settle first:** `{K V}` is
currently a legal return type, so `(defn f [...] {string i32} {:where ...} body)` puts two braces in a row currently a legal return type, so `(defn f [...] {string i32} {:where ...} body)` puts two braces in a row
meaning different things. That resolves itself if `{K V}` goes in favour of `(Map K V)`, which is a separate meaning different things.~~ — resolved the way this predicted: `{K V}` went in favour of `(Map K V)`,
open question in this file. braces in type position are refused by name, and the two-braces-in-a-row case cannot arise.
- A `where` clause over **compile-time type predicates** admits the operators the body needs. Four are wanted — - A `where` clause over **compile-time type predicates** admits the operators the body needs. ~~Four are
`ordered?`, `equal?`, `hashable?`, `numeric?` — against Odin's forty-one. The prelude's nine non-collapsing wanted — `ordered?`, `equal?`, `hashable?`, `numeric?`~~ — against Odin's forty-one. Five landed: `copyable?`
functions need only the first two. is the fifth, and it has no Odin counterpart because a `$T` there never has to answer whether it moves. The
prelude's nine non-collapsing functions need only the first two.
- Each instantiation checks the concrete type satisfies the predicates and refuses **that call site** if not. - Each instantiation checks the concrete type satisfies the predicates and refuses **that call site** if not.
**This is not a type class, and the distinction is the one to keep straight.** A type class carries **This is not a type class, and the distinction is the one to keep straight.** A type class carries
@ -1046,9 +1056,12 @@ header is now cached in the session as well as on disk, so a repeat import (a `C
`import` line) costs nothing, and a header edited mid-session is not picked up until the session restarts — the same `import` line) costs nothing, and a header edited mid-session is not picked up until the session restarts — the same
rule a changed `.c` file follows. rule a changed `.c` file follows.
**Opt-in on purpose.** `vendor/raylib/headers` is `?${FLAN_RAYLIB_H}`. "A build needs libraylib linkable and not ~~**Opt-in on purpose.** `vendor/raylib/headers` is `?${FLAN_RAYLIB_H}`. "A build needs libraylib linkable and not
raylib-devel installed" is a property chosen deliberately, and requiring a header would take it from everyone to give raylib-devel installed" is a property chosen deliberately, and requiring a header would take it from everyone to give
the check to whoever has one. Unset means off; set-and-wrong is an error naming the path. the check to whoever has one. Unset means off; set-and-wrong is an error naming the path.~~ — no longer so, and what
dissolved the argument was the commit rather than a change of mind about the property: `vendor/raylib/raylib-5.5.h` is
tracked, `headers` names that path with no `${...}` in front of it, `FLAN_RAYLIB_H` is gone, and the check runs on
every build. The `?` marker still means what it says here; this package is simply not using it any more.
Worth knowing before touching it: Worth knowing before touching it:

View File

@ -102,6 +102,19 @@ the agent's socket instead.
~BoundsError~ rather than ending the process, abandoning a frame and retrying ~BoundsError~ rather than ending the process, abandoning a frame and retrying
it is a real thing to do, and a non-idempotent mutation is what makes it go it is a real thing to do, and a non-idempotent mutation is what makes it go
wrong. wrong.
- *Four conditions come from below your program*, all with ~error~:
~StorageExhausted~ when an allocator cannot satisfy a request, ~FileError~
when a file operation fails, ~BoundsError~ for an index or slice outside its
container, and ~ArithError~ for arithmetic with no answer — a divide or
remainder by zero, ~INT64_MIN / -1~, and a float-to-integer cast that does not
fit, each of which used to be a bare ~SIGFPE~ with no message and no location.
The first two offer a ~retry~ at the failing site, because freeing something
or supplying another path makes the same operation succeed. The last two offer
*nothing*: no handler makes index 51 valid for a length-50 array or gives a
division by zero a quotient, so there is nothing to resume into. The restart
that answers those is the one your program already established — the frame
loop's ~continue~ — and it is on the stack and reachable without anything
being pushed at the failure.
- *An unknown restart name is a hard stop.* No ~find-restart~ to test with. - *An unknown restart name is a hard stop.* No ~find-restart~ to test with.
- *So are the wrong arguments*, and for the same reason: nothing static can - *So are the wrong arguments*, and for the same reason: nothing static can
know what a name will find. The message names both signatures. know what a name will find. The message names both signatures.

View File

@ -2615,6 +2615,12 @@ allocation guard, so a `retry` re-attempts the allocation and not the expression
literal's field list, and giving the same braces two meanings is what the colon-to-dot change was for. A map is built literal's field list, and giving the same braces two meanings is what the colon-to-dot change was for. A map is built
with `map-new` and filled with `put`. with `map-new` and filled with `put`.
*(Superseded on the first half only. `(Map K V)` is the type spelling and `{K V}` was withdrawn — braces in type
position are refused by name, because the brace's value and type meanings never corresponded the way the bracket's do
and `{}` in type position is wanted for anonymous struct types. The paragraph's actual subject is unchanged: there is
still no map literal, and a bare map form in expression position is still a struct literal's field list. See
`lib/parse.ml:68`.)*
### The refusals, each by name ### The refusals, each by name
- A **float key** — not a milestone question, which is why it is said separately. NaN is not equal to itself, and - A **float key** — not a milestone question, which is why it is said separately. NaN is not equal to itself, and
@ -3847,6 +3853,11 @@ raylib to open. Pointing raylib at embedded bytes needs `LoadImageFromMemory` an
`LoadTexture` — a raylib binding question, not an embedding one — so the flagship program is not yet asset-free on the `LoadTexture` — a raylib binding question, not an embedding one — so the flagship program is not yet asset-free on the
web. The mechanism it needs is in. web. The mechanism it needs is in.
*(Superseded by the cut. `sand.flan` was taken back from 765 lines to 206, to parity with the Clojure, Common Lisp and
jank ports, and the texture went with it — there is no `load-texture` call and no asset left to embed, only a
`brush-size` integer that kept the name. The binding question is still open for whatever wants it next; this program is
no longer the one asking it, and `brush.png` is now unreferenced.)*
## `slurp`, `barf`, and the two ways they fail ## `slurp`, `barf`, and the two ways they fail
NEXT.md decisions 2 and 5. `(slurp path)` and `(slurp path allocator)` read a whole file into a `(Vec u8)`; NEXT.md decisions 2 and 5. `(slurp path)` and `(slurp path allocator)` read a whole file into a `(Vec u8)`;

View File

@ -1,5 +1,16 @@
# The generics spike, answered: it runs, and the bill lands on the dev loop rather than on the checker # The generics spike, answered: it runs, and the bill lands on the dev loop rather than on the checker
> **This is the spike report and it stopped being current when generics landed for real, on 2026-09-13.**
> It is kept because the measurements and the reasoning behind the design are still the ones that were
> acted on, but it describes a branch where `lib/prelude.ml` and the backends were untouched, and they
> are not any more: the prelude's per-type families collapsed into one function each. Two things it says
> have since been overtaken and would mislead anyone writing code from it. Its account of plan.org is out
> of date — plan.org now specifies `$t` itself rather than lowercase-with-no-sigil. And the spelling rule
> below is narrower than what shipped: `$t` is written wherever a *type* goes, including in a return type
> and nested inside `[$t]` or `(Option $t)`, and bare `t` only where a type's *name* is an argument in
> expression position, as in `(vec-new t)` and the cast `(t x)`. `(Option t)` does not compile.
> plan.org's Types section and spec-memory.md's Generics section are the current account.
Milestone 5's parametric polymorphism, run early and deliberately out of order, as a spike rather than as a Milestone 5's parametric polymorphism, run early and deliberately out of order, as a spike rather than as a
decision. **Feasible, and smaller than expected.** A generic function written in Flan goes through the ordinary decision. **Feasible, and smaller than expected.** A generic function written in Flan goes through the ordinary
frontend, is instantiated at each concrete type its call sites ask for, is emitted as real functions and runs, frontend, is instantiated at each concrete type its call sites ask for, is emitted as real functions and runs,

View File

@ -16,7 +16,7 @@ and texpr_kind =
| Tname of string (* i32 bool Cursor string *) | Tname of string (* i32 bool Cursor string *)
| Tslice of texpr (* [u8] ptr+len *) | Tslice of texpr (* [u8] ptr+len *)
| Tarray of len * texpr (* [4 f32] [rows [cols u32]] *) | Tarray of len * texpr (* [4 f32] [rows [cols u32]] *)
| Tmap of texpr * texpr (* {string i32} *) | Tmap of texpr * texpr (* (Map string i32) *)
| Tapp of string * texpr list (* (Ptr Cursor) (Option f64) *) | Tapp of string * texpr list (* (Ptr Cursor) (Option f64) *)
| Tfn of texpr list * texpr (* (Fn [a a] bool) *) | Tfn of texpr list * texpr (* (Fn [a a] bool) *)

View File

@ -744,18 +744,20 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
by this backend — and the module that would redefine through them arrives by this backend — and the module that would redefine through them arrives
with the lane that writes it. with the lane that writes it.
That lane inherits one thing this comment should say out loud rather than [X86.redefinition] is that lane, and it has landed, so the paragraph this
leave for it to find. [x86.ml]'s header licenses its own calling comment used to end with — that no counterpart existed — is no longer
convention on the grounds that a dev build is compiled entirely by it and true. What remains true is why it had to be written here rather than
a release build entirely by LLVM, so the two never meet in one process. borrowed from LLVM. [x86.ml]'s header licenses its own calling convention
Publishing a cell an LLVM-built module can store into is the first thing on the grounds that a dev build is compiled entirely by it and a release
that could make that false: the two conventions agree on scalars and build entirely by LLVM, so the two never meet in one process. Publishing
disagree on every aggregate, so an [Emit.redefinition] module dlopened a cell an LLVM-built module can store into is the first thing that could
into an [--x86] host would be correct until the first redefined function make that false: the two conventions agree on scalars and disagree on
took or returned a struct. Nothing in the toolchain does that today — every aggregate, so an [Emit.redefinition] module dlopened into an
[flan reload] and [flan dev] both build host and module through LLVM — [--x86] host is correct until the first redefined function takes or
and the fix when something does is to emit the module through this returns a struct. That pair is now refused at [dlopen] by a marker symbol
backend too, not to grow a classifier. *) each backend defines and each backend's module references — see [shared]
below — rather than left to die at the call. The answer was to emit the
module through this backend too, and never to grow a classifier. *)
(* [--debug] used to be in this list too. It is not any more: [x86.ml] emits (* [--debug] used to be in this list too. It is not any more: [x86.ml] emits
a compile unit, a subprogram per function and a line table, all written a compile unit, a subprogram per function and a line table, all written
out as bytes because [.loc] cannot work against a file whose instructions out as bytes because [.loc] cannot work against a file whose instructions
@ -911,12 +913,17 @@ let shared ?(opts = default) ~ir ~out () : timing =
than it is is worse than none. It catches a caller holding one option than it is is worse than none. It catches a caller holding one option
record and reaching for the wrong builder. It does not catch a caller record and reaching for the wrong builder. It does not catch a caller
holding two and picking the wrong one — the crossed pair that was measured holding two and picking the wrong one — the crossed pair that was measured
segfaulting passes this check and still segfaults, because it hands an segfaulting passes this check, because it hands an LLVM record to the LLVM
LLVM record to the LLVM builder and simply loads the result into an x86 builder and simply loads the result into an x86 host. [flan reload] is
host. [flan reload] is precisely that caller. The complete answer is a precisely that caller, and it is why this guard was never the whole
marker symbol the host defines and a module references, so the loader answer. The whole answer is the marker symbol: a dev build defines
refuses the pair at dlopen rather than the processor refusing it at a [flan.abi.x86] or [flan.abi.llvm] according to which backend emitted it,
call. See docs/handoffs/HANDOFF-x86-aggregates.md. *) each backend's redefinition module holds a pointer to its own, and the
loader has to resolve that pointer while it maps the object — so a crossed
pair is refused at [dlopen], naming both backends, before any new body
runs. That landed; this check is the cheap first line rather than the only
one. See docs/handoffs/HANDOFF-x86-aggregates.md and
docs/handoffs/HANDOFF-x86-abi-marker.md. *)
if opts.x86 then if opts.x86 then
failwith failwith
"--x86: Build.shared is the LLVM redefinition path, and an --x86 host \ "--x86: Build.shared is the LLVM redefinition path, and an --x86 host \

View File

@ -17,7 +17,9 @@ and value =
| Byte of int (* \space \0 \( (0..255) *) | Byte of int (* \space \0 \( (0..255) *)
| List of t list (* (f x) *) | List of t list (* (f x) *)
| Vec of t list (* [1 2 3] and every binding/type bracket *) | Vec of t list (* [1 2 3] and every binding/type bracket *)
| Map of t list (* {.field v} a struct value, {K V} a type. The | Map of t list (* {.field v} a struct value, and a defn's
{:where ...} clause. Braces are not a type: the
{K V} spelling was withdrawn for (Map K V). The
colon spelling is left for map literals. *) colon spelling is left for map literals. *)
let make v loc = { v; loc } let make v loc = { v; loc }

View File

@ -1,8 +1,9 @@
(** The typed IR: what the checker produces and what every backend consumes. (** The typed IR: what the checker produces and what every backend consumes.
Three backends share this — the tree-walking interpreter, dev redefinition Every backend shares this — the LLVM emitter and the hand-written x86-64
and the release AOT build (plan.org, Compilation) — so everything a backend one, each of them under dev redefinition and under the release AOT build
would otherwise have to re-derive is resolved here and nowhere else: (plan.org, Compilation) — so everything a backend would otherwise have to
re-derive is resolved here and nowhere else:
- names are gone. A local is a slot index into the frame, a global is a - names are gone. A local is a slot index into the frame, a global is a
name, and a call names its callee directly. No environment lookup. name, and a call names its callee directly. No environment lookup.

View File

@ -28,7 +28,7 @@ type t =
| Enum of string | Enum of string
| Slice of t (* [T] ptr+len, non-owning *) | Slice of t (* [T] ptr+len, non-owning *)
| Array of int64 * t (* [n T] inline, a value, copies *) | Array of int64 * t (* [n T] inline, a value, copies *)
| Map of t * t (* {K V} *) | Map of t * t (* (Map K V) *)
| Ptr of t (* (Ptr T) *) | Ptr of t (* (Ptr T) *)
(* [Allocator]: a builtin opaque type, the way [string] is a builtin (* [Allocator]: a builtin opaque type, the way [string] is a builtin
ptr+len. It is a [Types.t] case with no user-writable constructor, which ptr+len. It is a [Types.t] case with no user-writable constructor, which

167
plan.org
View File

@ -101,7 +101,7 @@ world.
- Map keys initially use compiler-provided structural equality and hashing for - Map keys initially use compiler-provided structural equality and hashing for
integers, enums, strings, fixed arrays and value structs; pointers, slices and integers, enums, strings, fixed arrays and value structs; pointers, slices and
owning containers are excluded. A map is homogeneous, and empty construction 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 returns ~(Option V)~; ~put~ is the ~()~-returning upsert. See
spec-memory.md for the deferred move-aware operations. spec-memory.md for the deferred move-aware operations.
- Operations: ~get~, ~put~, ~remove~, ~push~, ~pop~, ~at~, ~len~, ~update~. - Operations: ~get~, ~put~, ~remove~, ~push~, ~pop~, ~at~, ~len~, ~update~.
@ -194,8 +194,10 @@ and on a managed ~class~ instance. An ordinary ~struct~ never carries one.
- Types are mandatory; *inference* makes them feel optional. Annotate function - Types are mandatory; *inference* makes them feel optional. Annotate function
signatures, infer locals — Odin/Zig/Rust ergonomics. signatures, infer locals — Odin/Zig/Rust ergonomics.
- Signatures are annotated as inline name/type pairs, as in ~let~ and - Signatures are annotated as inline name/type pairs, as in ~defstruct~ and a
~defstruct~: ~(defn area [s Shape] f32 ...)~. No separate ~declare~ form — ~restart-case~ clause: ~(defn area [s Shape] f32 ...)~. A ~let~ is not one of
them — a local is inferred from its initialiser and takes no annotation at
all. No separate ~declare~ form —
~declare~ is kept only where there is no body (forward declarations, FFI). ~declare~ is kept only where there is no body (forward declarations, FFI).
- Annotations at function boundaries are unavoidable, because compile-time - Annotations at function boundaries are unavoidable, because compile-time
overloading is incompatible with full inference. Locals are inferred. overloading is incompatible with full inference. Locals are inferred.
@ -401,8 +403,8 @@ primitives, and are never bootstrapped away.
The LLVM question does not bear on this: the release backend emits LLVM IR *as 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 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 2–5 Rust buy nothing here. What the choice actually turns on is that milestones 2–5
are a reader, a typed IR, a checker and a tree-walking interpreter — variants and are a reader, a typed IR, a checker and the code generators behind it — variants
exhaustive pattern matching, which is the one domain where OCaml is not a 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 preference but a clear win. There is also a menhir lexer/parser already started
in ~old-ocaml/~. in ~old-ocaml/~.
@ -415,7 +417,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. compiler to maintain forever. Choose as if the host language is permanent.
* Milestone-2 primitives * 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 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. wasm32 target cheap, because a primitive is the only thing implemented twice.
@ -477,33 +479,54 @@ is Clojure's ~ns~ form: no path that must mirror the directory, no
root-directory aliases. root-directory aliases.
* Compilation * Compilation
*Two backends and three paths.* The split is not dev-vs-release; it is *One evaluator and three paths.* The split is not dev-vs-release; it is
/does this code have a frame budget/. /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 docs/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 #+begin_src
expression eval: flan → typed IR → interpreter ~1ms expression eval: flan → typed IR → .ll → llc → ld -shared → dlopen → call
dev redefinition: flan → typed IR → .ll → llc → ld -shared → dlopen → cell store ~19ms (MEASURED)
~16ms (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} release build: flan → typed IR → .ll → clang --target={native,wasm32}
#+end_src #+end_src
*Hard requirement: eval is immediate.* Not "fast enough for a build" — immediate, *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 because the whole point of the live loop is that you see the result. 19ms is
frame at 60fps and under the ~50ms threshold where a response stops feeling around one frame at 60fps and under the ~50ms threshold where a response stops
instantaneous. The rule that buys it: *never invoke the ~clang~ driver on the dev feeling instantaneous. The rule that buys it: *never invoke the ~clang~ driver on
path.* the dev path.*
*Expression eval* — ~C-c C-e~, calling a function, inspecting a var, running a *Expression eval* — ~C-x C-e~, calling a function, inspecting a var, running a
test — goes to the tree-walking interpreter. Sub-millisecond, no subprocess. This test — is compiled like everything else, into its own shared object, which is
is the permanent REPL backend, not a milestone-2 scaffold. 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 *Dev redefinition* — ~C-c C-c~ on a function inside a running game — has an 8ms
the interpreter, because that code has an 8ms frame budget. It recompiles the one frame budget to respect. It recompiles the one function, links it, and does the
function, links it, and does the atomic indirection-cell store. This is what the atomic indirection-cell store. This is what the Hot reload section has always
Hot reload section has always described; the interpreter does not replace it. 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. *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 ** Measured redefinition latency
Single function, x86-64, clang 20.1.8, 20 iterations each: Single function, x86-64, clang 20.1.8, 20 iterations each:
@ -563,9 +586,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 settled, and is independent of whether the /language/ exposes threads, which
is still open decision #4. is still open decision #4.
It also bears on whether the interpreter survives: if the agent can ~dlopen~ and This is also what settled the interpreter question: if the agent can ~dlopen~ and
call anything in 16ms, then even "eval this expression against live game state" call anything in under 20ms, then even "eval this expression against live game
can be a compiled ~.so~, and no interpreter is needed inside the game process. 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 ** Why LLVM IR as text
| | text ~.ll~ → ~clang~ | libLLVM bindings | emit C | | | text ~.ll~ → ~clang~ | libLLVM bindings | emit C |
@ -580,32 +604,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 loss is ~13ms — below perception. ORC remains addable later behind the same typed
IR without touching the language, but nothing currently argues for it. IR without touching the language, but nothing currently argues for it.
** The interpreter cannot run sand ** The interpreter, and why there is not one
Do not plan around it. 200 × 280 = 56,000 cells, scanned by ~game-update~ and An interpreter could never have run sand, and that was the first half of the
again by ~game-draw~ — ~112,000 interpreted cell-visits per frame against an argument. 200 × 280 = 56,000 cells, scanned by ~game-update~ and again by
8.3ms budget at 120fps. At an optimistic 100ns per visit (environment ~game-draw~ — ~112,000 interpreted cell-visits per frame against an 8.3ms budget
allocation, argument binding, two index computations, a compare) that is 11ms at 120fps. At an optimistic 100ns per visit (environment allocation, argument
before ~settle~, ~paint~, or a single raylib call. Expect 20–30fps. binding, two index computations, a compare) that is 11ms before ~settle~,
~paint~, or a single raylib call. Expect 20–30fps. 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 *** Settled: the compiled path is the only backend
measured interpreter throughput number* — before milestone 4 depends on it. This was open decision #7 and it is closed. Compiled redefinition measured at
Milestone 4's interactive acceptance test runs on the compiled dev build; the ~19ms is perceptually instant for expression eval too, so the one thing an
interpreter is not in that loop. 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. docs/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? Consequences applied elsewhere in this document: milestone 2's "interpreted calls
Now that compiled redefinition is measured at 16ms, the case for a /permanent/ per second" exit criterion is dropped, and the host ABI moved onto the critical
interpreter is weaker than it looked. 16ms is perceptually instant for expression path in its place.
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.
Against dropping it: the interpreter is clearly right for milestone 2 (far less The paths that remain share the frontend and the typed IR and must agree on
work than an LLVM backend, better error messages, no linking), and the observable behaviour. That agreement is what the acceptance programs test, and
instrumentation-based step debugger wants it. Decide at milestone 3 exit on for the two code generators it is tested byte for byte.
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.
- Non-local exit lowered *explicitly* (result propagation + branch targets), not - Non-local exit lowered *explicitly* (result propagation + branch targets), not
via platform unwinding. Same on both targets, no dependency on the WASM via platform unwinding. Same on both targets, no dependency on the WASM
@ -635,7 +663,7 @@ Deliberately different.
| | Dev | Release | | | Dev | Release |
|---------+---------------------------+------------| |---------+---------------------------+------------|
| Backend | interpreter /and/ LLVM | LLVM/clang | | Backend | LLVM, or ~--x86~ | LLVM/clang |
| Calls | indirection cells | direct | | Calls | indirection cells | direct |
| Code | never freed | static | | Code | never freed | static |
| Frames | shadow stack | none | | Frames | shadow stack | none |
@ -674,6 +702,9 @@ nses-of-symbols and middleware. Treat "speaks nREPL" as milestone 7a and "an
editor client that is pleasant" as a separate milestone 7b. In the dev runtime: editor client that is pleasant" as a separate milestone 7b. In the dev runtime:
- eval string in package; compile form/file with source locations - eval string in package; compile form/file with source locations
- completion, arglist, describe, find-definition - completion, arglist, describe, find-definition
- macroexpand, one step or to the fixpoint — the compiler builds the macro into a
shared object and dlopens it to run the expansion, which is the same route a
file's own macros take
- backtrace + restarts; interrupt - backtrace + restarts; interrupt
** Emacs client ** Emacs client
@ -698,8 +729,11 @@ trap handling, frame unwinding), duplicating an enormous existing project.
Neither covers the other's column, so this is not a choice between them. 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 *DAP is nearly free.* No debug adapter is written: emit DWARF from the backend
backend and point ~lldb-dap~ at the binary; dape speaks to that. lldb and gdb 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. both ship DAP interfaces already.
This is where /no object headers/ pays off a second time. Flan structs *are* C This is where /no object headers/ pays off a second time. Flan structs *are* C
@ -756,17 +790,21 @@ building the whole live environment at once.
1. *Freeze the model.* spec-memory.md and spec-conditions.md — done before any 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, code. Fixed arrays, non-owning slices, move-only ~Vec~/~Map~, allocators,
~Ptr~, explicit ~clone~; the six restart cases. /Done./ ~Ptr~, explicit ~clone~; the six restart cases. /Done./
2. *Run calc-me.flan on the interpreter.* Reader, typed IR, checker, 2. *Run calc-me.flan.* Reader, typed IR, checker, and a backend that can carry
tree-walking backend. /Exit criterion includes a measured throughput number/ the program end to end. The exit criterion was once a measured interpreter
— interpreted calls per second on a tight loop — because milestone 4's frame throughput number; with no interpreter that criterion is gone and the narrow
budget depends on it (see Compilation). Packages, structs, ~(Ptr T)~ + ~addr~, byte slices, 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~ ~at~/~len~, ~while~, ~set~ on the fixed place list, ~cond~, ~match~, ~Option~
+ ~some~, ~i32~/~u8~/~f64~, recursion, argv, stdout. No allocator, no ~Vec~, + ~some~, ~i32~/~u8~/~f64~, recursion, argv, stdout. No allocator, no ~Vec~,
no generics, no user macros, no FFI, no window. Headless, so the acceptance no generics, no user macros, no FFI, no window. Headless, so the acceptance
test is a table of expression/result pairs. 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. 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. 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 4. *Run sand.flan.* Fixed 2-D arrays, ~dotimes~, ~defer~, and typed FFI to
raylib including keyword→enum coercion. Acceptance test twice: headless (N raylib including keyword→enum coercion. Acceptance test twice: headless (N
frames, hash the grid — runnable in CI on both targets) and interactive at frames, hash the grid — runnable in CI on both targets) and interactive at
@ -776,8 +814,8 @@ building the whole live environment at once.
special forms in the compiler. special forms in the compiler.
6. *Allocators, ~Vec~/~Map~, ~Result~/~try~/~errdefer~, then conditions and 6. *Allocators, ~Vec~/~Map~, ~Result~/~try~/~errdefer~, then conditions and
restarts* against spec-conditions.md, with dedicated tests per numbered case. restarts* against spec-conditions.md, with dedicated tests per numbered case.
7. *Hot reload* — free in the interpreter, indirection cells for compiled dev 7. *Hot reload* — indirection cells in dev builds, with signature generations
builds, with signature generations and stale-caller warnings, plus the and stale-caller warnings, plus the
remaining compatibility limits written down and enforced: struct layout remaining compatibility limits written down and enforced: struct layout
changes, live callbacks held by C, captured environments. changes, live callbacks held by C, captured environments.
8. *Debugger, nREPL, async* — last, and 8 splits into transport (8a) and editor 8. *Debugger, nREPL, async* — last, and 8 splits into transport (8a) and editor
@ -823,8 +861,8 @@ monomorphisation, no restarts and no reload.
None of these block milestone 2. The milestone each one must be answered by is None of these block milestone 2. The milestone each one must be answered by is
marked. marked.
1. *Host language: OCaml or Rust* — the only thing blocking the scaffold. See 1. *Host language: OCaml or Rust* — /settled: OCaml,/ and the compiler has been
Host language. /Milestone 2./ written in it since. See Host language for what the choice turned on.
2. Macro hygiene is settled for milestone 5: explicit ~gensym~, deliberately 2. Macro hygiene is settled for milestone 5: explicit ~gensym~, deliberately
non-hygienic expansion, no local macros until a concrete use case appears. non-hygienic expansion, no local macros until a concrete use case appears.
3. Borrow checking and escaping frame-arena values. /Deferred; revisit after 3. Borrow checking and escaping frame-arena values. /Deferred; revisit after
@ -855,7 +893,8 @@ marked.
a ~defvar~. Each needs an answer of the form "rejected", "accepted with a a ~defvar~. Each needs an answer of the form "rejected", "accepted with a
migration", or "accepted and the old code keeps running". migration", or "accepted and the old code keeps running".
7. Does the interpreter survive milestone 3, or is the compiled path the only 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 docs/BUILT.md's "Why there is no interpreter".
8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the 8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the
compiler niche-optimises ~(Option (Ptr T))~ to a nullable pointer. The compiler niche-optimises ~(Option (Ptr T))~ to a nullable pointer. The
CL-vs-Clojure truthiness question is moot under static typing. CL-vs-Clojure truthiness question is moot under static typing.
@ -875,8 +914,10 @@ marked.
- Dev redefinition latency → ~16ms, measured: ~llc~ + ~ld -shared~ + ~dlopen~, - Dev redefinition latency → ~16ms, measured: ~llc~ + ~ld -shared~ + ~dlopen~,
never the ~clang~ driver, ~dlopen~ off the game thread, cells published in a never the ~clang~ driver, ~dlopen~ off the game thread, cells published in a
batch at a frame boundary. See Compilation. batch at a frame boundary. See Compilation.
- Dev backend → interpreter for milestone 2 certainly. Whether it /survives/ - Dev backend → compiled, and only compiled. The interpreter that milestone 2
milestone 3 is open, not settled — see Compilation. 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~. - ~set~ on places → a fixed list of assignable forms, not ~setf~.
- Loop story → imperative ~while~/~until~/~dotimes~ with ~break~/~continue~ and - Loop story → imperative ~while~/~until~/~dotimes~ with ~break~/~continue~ and
~return~; ~loop~/~recur~ only if it later earns its place. It did: both are ~return~; ~loop~/~recur~ only if it later earns its place. It did: both are

View File

@ -139,6 +139,31 @@ non-idempotent mutation bites. §3's rule that every clause body and the body
share a type places the restart syntactically; nothing places it *semantically*, share a type places the restart syntactically; nothing places it *semantically*,
and that choice is the author's. and that choice is the author's.
**Which of the runtime's own conditions establish a restart, and why only some
do.** Four are signalled from below the program with `error`: `StorageExhausted`
when an allocator cannot satisfy a request, `FileError` when a file operation
fails, `BoundsError` for an index or a slice outside its container, and
`ArithError` for an arithmetic operation that has no answer — a divide or
remainder by zero, `INT64_MIN / -1`, and a float-to-integer cast whose value does
not fit, each of which was a raw `SIGFPE` or an undefined result before it was a
condition. The first two establish a `retry` restart at the failing site, because
their attempt is repeatable: a handler frees something or supplies another path
and the same operation then succeeds. The last two establish **nothing**, and
that is a decision rather than an omission. Nothing a handler can do makes index
51 valid for a length-50 array or makes a division by zero have a quotient, so
there is no attempt to resume into. A site restart would also have to be
allocated by the `restart-case` that offers it, on its own stack (§3), which
means an `alloca` and a push/pop pair emitted at every indexing and every
division in every checked build — and what it would buy is a *different* answer,
silently.
So the rule this section describes is unchanged by them: the restarts that matter
for a bad index or a bad division are the ones the program already established —
a frame loop's `continue` — and those are on the restart stack and reachable from
a handler or from the break loop without anything being pushed at the failing
site. Allocation and file failure are the named exceptions, and spec-memory.md's
"Allocation failure" says why they have to be.
## 6. Crossing compiler-generated frames ## 6. Crossing compiler-generated frames
Transfer is lowered **explicitly** — result propagation plus branch targets — not Transfer is lowered **explicitly** — result propagation plus branch targets — not

View File

@ -38,12 +38,16 @@ enums, strings, fixed arrays, and value structs composed recursively from those
types. Tuples and triples join that set when they are introduced. `Ptr`, slices, types. Tuples and triples join that set when they are introduced. `Ptr`, slices,
`Vec`, and `Map` are not map keys yet. `Vec`, and `Map` are not map keys yet.
Equality and hashing for those keys are compiler-provided structural operations, Equality and hashing for those keys are compiler-provided structural operations
not type classes and not operations available to an unconstrained type variable. and not type classes. They are not available to an unconstrained type variable
An empty map takes its type from its context: either; a variable that means to key a map declares `hashable?` in the signature
that binds it, and the refusal then lands at the call site that names an
unhashable key. An empty map names its key and value types, because a global
cannot hold one and there is therefore no declaration for it to take a type from:
``` ```
(defvar enemies (Map string Enemy) (map-new)) (let [enemies (map-new string Enemy)]
...)
``` ```
`(get m k)` returns `(Option V)`: absence is `None`, not an untyped `nil`. `(get m k)` returns `(Option V)`: absence is `None`, not an untyped `nil`.
@ -128,26 +132,54 @@ visible in the type:
## Generics ## Generics
Parametric polymorphism is monomorphisation, with **no type classes and no Parametric polymorphism is monomorphisation, with **no type classes**. A type
constraints**. The consequence is a hard rule: variable is written `$t` wherever a *type* goes — a parameter, the return type,
or nested as `[$t]` or `(Vec $t)` — and bare `t` where a type's *name* is an
argument in expression position, as in `(vec-new t)` and the cast `(t x)`. A
generic body is checked **abstractly**, with nothing substituted, so the rule
below bites at the definition rather than at whichever call site first
instantiates it:
> A type variable `a` supports only what every type supports: move, `clone`, > A type variable `$t` supports only what every type supports: move, `clone`,
> field-free storage. It does **not** support `=`, `<`, `+`, or `hash`. > field-free storage. It does **not** support `=`, `<`, `+`, or `hash`.
Anything else is passed in explicitly as a function value: What makes that liveable is a `where` clause of compile-time type predicates,
written as a map at the head of the body. There are five — `ordered?`, `equal?`,
`hashable?`, `numeric?`, `copyable?` — they are not type classes because a
predicate carries no implementations and merely gates a builtin the compiler
already has, and they entail one another in one direction, so one clause usually
does. A variable is move-only by default and `copyable?` is the opt-out, because
whether a variable moves is not decidable abstractly. plan.org's Types section
has the full account.
``` ```
(defn largest [xs [a] gt (Fn [a a] bool)] (Option a) ...) (defn sort! [s [$t]] ()
{:where (ordered? $t)}
...)
``` ```
Ordered/arithmetic operators over `a` are therefore rejected, not silently Without such a clause the operator is rejected where it is written, not silently
instantiated. The alternatives — compile-time interfaces, or intrinsics instantiated, and the operation is passed in explicitly as a function value
restricted to primitives — are deliberately deferred until the base checker is instead:
stable (build sequence milestone 4).
```
(defn largest [xs [$t] gt (Fn [$t $t] bool)] (Option $t) ...)
```
The value handed to such a parameter is a named `defn`. An `fn` cannot be written
inline into it, because the generic body is checked with nothing substituted and
there is no concrete type yet for the `fn`'s own parameters to come from; that
restriction lifts at a monomorphic call site, where `reduce`'s and `filter`'s
callbacks are ordinary inline `fn`s.
The alternatives to predicates — compile-time interfaces, or intrinsics
restricted to primitives — remain deliberately deferred until the base checker is
stable (build sequence milestone 4). The ceiling is that nobody can supply a
user-defined `<`.
`println` is the deliberate exception. It is a compiler-provided, `println` is the deliberate exception. It is a compiler-provided,
type-directed intrinsic: monomorphisation selects or emits a structural printer type-directed intrinsic: monomorphisation selects or emits a structural printer
for each concrete instantiation, so `(println x)` is legal for `x : a` without for each concrete instantiation, so `(println x)` is legal for `x : $t` without
introducing a `Printable` type class. Structs, fixed arrays, options and, introducing a `Printable` type class. Structs, fixed arrays, options and,
eventually, Vecs and Maps print structurally. `Ptr` and `Handle` print their eventually, Vecs and Maps print structurally. `Ptr` and `Handle` print their
address or identity rather than recursively dereferencing, and collection address or identity rather than recursively dereferencing, and collection

View File

@ -2,11 +2,13 @@
;; ;;
;; Rules held here: ;; Rules held here:
;; - every type notation reads as exactly ONE data item ;; - every type notation reads as exactly ONE data item
;; - types are inline name/type pairs, as in `let` and `defstruct` ;; - types are inline name/type pairs, as in `defstruct` and a restart-case
;; clause. NOT in `let`: a local is inferred and takes no annotation
;; - the return type is always written; () is unit, a real zero-sized type ;; - the return type is always written; () is unit, a real zero-sized type
;; rather than C's void ;; rather than C's void
;; - lowercase type names are variables, Capitalized are concrete ;; - a type VARIABLE is $t; every other type name is concrete, whatever its
;; - no `!` convention (nothing is immutable), no `->`, no sigils ;; case. Lowercase-is-a-variable was the first spelling and is gone
;; - no `!` convention (nothing is immutable), and no `->`
;; ;;
;; Normative references: spec-memory.md (ownership, containers, places, ;; Normative references: spec-memory.md (ownership, containers, places,
;; generics, function values) and spec-conditions.md (restart semantics). ;; generics, function values) and spec-conditions.md (restart semantics).
@ -31,8 +33,8 @@
;; $t)}, is the other brace form, and it sits after the return type. ;; $t)}, is the other brace form, and it sits after the return type.
;; (Ptr World) pointer ;; (Ptr World) pointer
;; (Fn [f32] bool) function pointer, no captured environment ;; (Fn [f32] bool) function pointer, no captured environment
;; (Option a) union from the stdlib ;; (Option $t) union from the stdlib
;; (Handle a) generational handle into a pool ;; (Handle $t) generational handle into a pool
;; ;;
;; A struct is a value type iff all its fields are. One Vec field makes it ;; A struct is a value type iff all its fields are. One Vec field makes it
;; move-only. Copying an owning container is always explicit: (clone v). ;; move-only. Copying an owning container is always explicit: (clone v).
@ -57,22 +59,51 @@
(Circle r) (* PI r r) (Circle r) (* PI r r)
(Rect w h) (* w h))) (Rect w h) (* w h)))
;; ── Lowercase = type variable. Monomorphised at each call site ──────── ;; ── $t binds a type variable. Monomorphised at each call site ─────────
;; There are no type classes, so `a` supports only what EVERY type supports. ;; The sigil is on the type, everywhere a type goes: [$t], (Fn [$t $t] bool),
;; Ordering is not that — it is passed in as a function value. Type arguments ;; (Option $t). Bare t is the same variable where a type's NAME is an argument
;; are inferred from the argument types; there is no explicit instantiation. ;; in expression position — (vec-new t), (map-new t i32), the cast (t x).
;; The inner `fn` captures `gt`, a parameter: legal because it does not outlive ;; There are no type classes, so $t supports only what EVERY type supports, and
;; this frame (spec-memory.md, non-escaping fn). ;; the body is checked abstractly, so an unsupported operation is an error here
(defn largest [xs [a] gt (Fn [a a] bool)] (Option a) ;; rather than at the first call site that happened to instantiate it. Ordering
;; is not supported, so it is passed in as a function value. Type arguments are
;; inferred from the argument types; there is no explicit instantiation.
(defn largest [xs [$t] gt (Fn [$t $t] bool)] (Option $t)
{:where (copyable? $t)}
(if (> (len xs) 0) (if (> (len xs) 0)
(Some (reduce (fn [x y] (if (gt x y) x y)) (at xs 0) xs)) (let [best (at xs 0)]
(dotimes [i (len xs)]
(when (gt (at xs i) best) (set best (at xs i))))
(Some best))
None)) None))
;; (largest hps >) — `>` at i32 is an ordinary function value ;; A {:where ...} clause admits the operator instead of taking it as an
;; (largest es (fn [x y] (> (.hp x) (.hp y)))) ;; argument. Five predicates — ordered? equal? hashable? numeric? copyable? —
;; and each instantiation is checked against the ones the signature declares.
(defn smallest [xs [$t]] (Option $t)
{:where (ordered? $t)}
(if (> (len xs) 0)
(let [m (at xs 0)]
(dotimes [i (len xs)] (set m (min m (at xs i))))
(Some m))
None))
;; (largest hps taller) — a top-level defn is an ordinary function value.
;; An OPERATOR is not: `>` is not a name, and (largest hps >) is "unknown name
;; >". Nor can an `fn` be written inline into a (Fn [$t $t] bool) argument: the
;; generic body is checked with nothing substituted, so there is no type for the
;; fn's own parameters to come from yet. Inside a generic the callback is a
;; named defn; at a monomorphic call site, where the types are already
;; concrete, the fn can be written inline where it is used.
;; Parameters are immutable values; pass a pointer to mutate. `[Enemy]` is a ;; Parameters are immutable values; pass a pointer to mutate. `[Enemy]` is a
;; borrowed slice — centroid neither owns nor frees the storage. ;; borrowed slice — centroid neither owns nor frees the storage.
;; This one is still a sketch of where the syntax is going and does not compile
;; today, on two counts worth naming rather than leaving to be discovered:
;; component-wise `+` and `/` over a fixed array are planned and not built, and
;; the prelude's `reduce` is (reduce s init f) with its accumulator at the
;; ELEMENT type, so it cannot fold an [Enemy] into a Vec2. Written against what
;; exists, this is a `dotimes` accumulating into a local.
(defn centroid [es [Enemy]] Vec2 (defn centroid [es [Enemy]] Vec2
(/ (reduce (fn [acc e] (+ acc (.pos e))) [0 0] es) (/ (reduce (fn [acc e] (+ acc (.pos e))) [0 0] es)
(f32 (len es)))) (f32 (len es))))
@ -122,7 +153,9 @@
;; load-texture cannot know the right recovery — an editor wants a placeholder, ;; load-texture cannot know the right recovery — an editor wants a placeholder,
;; a release build wants to abort, a hot-reload session wants to retry after the ;; a release build wants to abort, a hot-reload session wants to retry after the
;; file is fixed on disk. So it offers a menu and the caller chooses. ;; file is fixed on disk. So it offers a menu and the caller chooses.
(defcondition AssetMissing [path string]) ;; A condition type is an ordinary struct — there is no defcondition, and no
;; class hierarchy to put one in. Matching is by type plus a predicate.
(defstruct AssetMissing [path string])
;; `signal` has type () and RETURNS if every handler returns normally, so the ;; `signal` has type () and RETURNS if every handler returns normally, so the
;; fall-through path of a restart-case in value position must still produce the ;; fall-through path of a restart-case in value position must still produce the
@ -139,10 +172,14 @@
;; Intermediate frames say nothing about AssetMissing. Nothing to thread. ;; Intermediate frames say nothing about AssetMissing. Nothing to thread.
;; invoke-restart has type Never: it does not return to the handler. ;; invoke-restart has type Never: it does not return to the handler.
(defn load-level [path string] () Level ;; A handler clause is (Type [name] body ...) — the type, then the one binding,
(handler-bind [AssetMissing (fn [c] ;; then the body. It is not a type paired with an `fn`, and a handler closes
(log "missing asset:" (.path c)) ;; over nothing: it is lifted into its own function, so a value it wants to keep
(invoke-restart 'use-placeholder))] ;; goes on the condition or into a global.
(defn load-level [path string] Level
(handler-bind [(AssetMissing [c]
(log "missing asset:" (.path c))
(invoke-restart 'use-placeholder))]
(parse-level (slurp path)))) (parse-level (slurp path))))
;; A handler that returns normally does not unwind, so the signaller carries on. ;; A handler that returns normally does not unwind, so the signaller carries on.
@ -160,10 +197,9 @@
(defn collect-parse-errors [src string] (Result Ast) (defn collect-parse-errors [src string] (Result Ast)
(let [errors (make-vec ParseError)] (let [errors (make-vec ParseError)]
(handler-bind [ParseError (fn [c] (handler-bind [(ParseError [c]
(push errors c) ; value struct: copies out of (push errors c) ; value struct: copies out of
; the signalling frame (invoke-restart 'skip-form))] ; the signalling frame
(invoke-restart 'skip-form))]
(let [ast (parse-all (parser src))] (let [ast (parse-all (parser src))]
(if (zero? (len errors)) (if (zero? (len errors))
(Ok ast) (Ok ast)

9
web/examples/arith.flan Normal file
View File

@ -0,0 +1,9 @@
;; Three integer operations have no right answer. Each used to be a bare SIGFPE
;; or an undefined value; each signals ArithError now. The divisor goes through
;; a global so that constant folding cannot answer it before the backend does.
(defvar zero i32 0)
(defn main [] ()
(println "before")
(println (/ 10 zero))
(println "unreachable"))

3
web/examples/arith.out Normal file
View File

@ -0,0 +1,3 @@
before
arith.flan:8:12: divide by zero: (/ 10 0)
exit 134

7
web/examples/cast.flan Normal file
View File

@ -0,0 +1,7 @@
;; A float-to-integer cast whose value does not fit. The condition it violated
;; is reported as the range the destination type can hold, which is the same
;; shape BoundsError uses for a slice: the violated condition, written out.
(defvar big f64 1e30)
(defn main [] ()
(println (i32 big)))

2
web/examples/cast.out Normal file
View File

@ -0,0 +1,2 @@
cast.flan:7:17: this value does not fit the integer type it is cast to, which holds [-2147483648 2147483647]
exit 134

View File

@ -1,6 +1,6 @@
42 42
1.5 1.5
(Enemy {:hp 3 :name "wisp" :key :left}) (Enemy {.hp 3 .name "wisp" .key :left})
(some 32) (some 32)
none none
no newline: true no newline: true

View File

@ -434,6 +434,57 @@ Measured cost on a
50-million-iteration dependency chain over a 1024-element array: 0.11–0.12s checked 50-million-iteration dependency chain over a 1024-element array: 0.11–0.12s checked
against 0.12–0.13s unchecked.</p> against 0.12–0.13s unchecked.</p>
<h3>So is arithmetic that has no answer</h3>
<p>Three integer operations have no right result, and each of them used to be a bare
<code>SIGFPE</code> or an undefined value: a divide or remainder by zero, the one division
that overflows (<code>INT64_MIN / -1</code>, whose true quotient is one past the top of
the type), and a float-to-integer cast whose value does not fit. All three now signal
<code>ArithError</code>, the way a bad index signals <code>BoundsError</code>.</p>
<pre><code>;; Three integer operations have no right answer. Each used to be a bare SIGFPE
;; or an undefined value; each signals ArithError now. The divisor goes through
;; a global so that constant folding cannot answer it before the backend does.
(defvar zero i32 0)
(defn main [] ()
(println "before")
(println (/ 10 zero))
(println "unreachable"))</code></pre>
<pre><code class="sh">$ flan run arith.flan
before
arith.flan:8:12: divide by zero: (/ 10 0)
$ echo $?
134
$ flan run cast.flan
cast.flan:7:17: this value does not fit the integer type it is cast to, which
holds [-2147483648 2147483647]</code></pre>
<pre><code>;; A float-to-integer cast whose value does not fit. The condition it violated
;; is reported as the range the destination type can hold, which is the same
;; shape BoundsError uses for a slice: the violated condition, written out.
(defvar big f64 1e30)
(defn main [] ()
(println (i32 big)))</code></pre>
<p>A Lisp that stops naming the file and the line beats one that dies with
<code>SIGFPE</code>, and a program that genuinely does not care installs a handler once at
startup and never thinks about it again. Float division is deliberately left alone: IEEE
already answers it, with an infinity or a NaN.</p>
<p><strong>No restart is established at the failing operation</strong>, which is the same
decision <code>BoundsError</code> made and for the same reason. A restart frame is
allocated by the <code>restart-case</code> that offers it, on that frame's own stack, so
nothing below the program can push one on its behalf; a <code>use-value</code> at a
division would mean an <code>alloca</code> and a push-and-pop emitted at every division in
every checked build, and what it would buy is a silently different answer. What answers a
division by zero is the restart the program already had — a frame loop's
<code>continue</code> — which is on the stack and reachable from a handler or from the
break loop without anything being pushed at the failure.</p>
<h2 id="types">Types</h2> <h2 id="types">Types</h2>
<p>Types are annotated at function boundaries and inferred everywhere else. Every type <p>Types are annotated at function boundaries and inferred everywhere else. Every type
@ -946,7 +997,7 @@ user-supplied printer to choose between.</p>
<pre><code class="sh">42 <pre><code class="sh">42
1.5 1.5
(Enemy {:hp 3 :name "wisp" :key :left}) (Enemy {.hp 3 .name "wisp" .key :left})
(some 32) (some 32)
none none
no newline: true</code></pre> no newline: true</code></pre>
@ -1248,9 +1299,10 @@ not in a <code>defer</code>, because a defer runs on the ordinary return path to
that version silently rolls back the frames that succeeded.</p> that version silently rolls back the frames that succeeded.</p>
<p>This matters more here than in most Lisps because the intended use is a <p>This matters more here than in most Lisps because the intended use is a
<em>game loop</em>, where the plan is to skip a frame and carry on rather than die. Now <em>game loop</em>, where the plan is to skip a frame and carry on rather than die. Now
that a bad index signals <code>BoundsError</code> instead of ending the process, that a bad index signals <code>BoundsError</code> and a bad division signals
abandoning a frame and retrying it is a real thing to do — and that is exactly the case <code>ArithError</code> instead of ending the process, abandoning a frame and retrying
a non-idempotent mutation spoils.</p></li> it is a real thing to do — and that is exactly the case a non-idempotent mutation
spoils.</p></li>
<li><strong>An unknown restart name is a hard stop</strong> — a located runtime error. <li><strong>An unknown restart name is a hard stop</strong> — a located runtime error.
There is no <code>find-restart</code> to test with yet.</li> There is no <code>find-restart</code> to test with yet.</li>
<li><strong>No supertype</strong>, so nothing can say "any condition".</li> <li><strong>No supertype</strong>, so nothing can say "any condition".</li>
@ -1394,7 +1446,7 @@ the same <code>declare-c</code> line a person would have written, and writes the
<code>generated.flan</code> in the package — which is <em>committed</em>.</p> <code>generated.flan</code> in the package — which is <em>committed</em>.</p>
<pre><code class="sh">$ flan generate-c vendor/raylib <pre><code class="sh">$ flan generate-c vendor/raylib
wrote vendor/raylib/generated.flan: 269 declarations, 117 refused, of 581 functions wrote vendor/raylib/generated.flan: 268 declarations, 117 refused, of 581 functions
in vendor/raylib/raylib-5.5.h. in vendor/raylib/raylib-5.5.h.
Every defstruct, every hand-written declare-c and every mapped Every defstruct, every hand-written declare-c and every mapped
constant agrees with it.</code></pre> constant agrees with it.</code></pre>
@ -1833,19 +1885,32 @@ own internal calling convention (every aggregate by pointer, no eightbyte rule,
classifier) and match SysV only at the C boundary, where the shim has already flattened classifier) and match SysV only at the C boundary, where the shim has already flattened
every struct.</p> every struct.</p>
<p>It covers a subset of the IR and <strong>refuses the rest by name</strong>, so a <p>It <strong>refuses by name</strong> anything it does not lower, so a build that
build that succeeds is one it really compiled rather than one it half-compiled. succeeds is one it really compiled rather than one it half-compiled. Conditions were the
Conditions are the visible gap — anything reaching the transfer channel is refused:</p> visible gap once and are not any more: the transfer channel, the guard after every call,
bounds and arithmetic failures, indirection cells, redefinition modules and DWARF line
tables all landed, and what is left refused is narrow — an aggregate crossing the C
boundary is the one worth naming, because closing it would mean the eightbyte classifier
this backend is built on not having.</p>
<pre><code class="sh">$ flan build test/programs/algorithms.flan --x86 <p>What holds it honest is that every program in the corpus is built both ways and the
Fatal error: exception Flan.X86.Unsupported("restart-case needs the transfer two are compared byte for byte on stdout, stderr and exit status — not on a disassembly,
channel, which this backend does not emit a guard for")</code></pre> which has read perfectly beside a wrong answer more than once. <code>spike/x86/survey.sh</code>
is the script, and it currently reports <strong>103 MATCH, 0 DIFFER, 0 refused by
name</strong>, with 38 programs skipped because they do not compile on either side, have
no <code>main</code>, or run forever. <code>dune build @x86</code> runs it as part of the
build, so a refusal cannot sit unnoticed.</p>
<p><code>--debug</code> is a third flag beside <code>--dev</code> and the optimisation <p><code>--debug</code> is a third flag beside <code>--dev</code> and the optimisation
level. <code>--dev</code> asks whether you can redefine the program while it runs; level. <code>--dev</code> asks whether you can redefine the program while it runs;
<code>--debug</code> asks whether you can stop it and read it. It emits DWARF, sets <code>--debug</code> asks whether you can stop it and read it. It emits DWARF, sets
<code>-O0</code>, and is refused by name for wasm32. lldb needs no plugin to read a <code>-O0</code>, and is refused by name for wasm32. lldb needs no plugin to read a
Flan struct: the struct is its C struct.</p> Flan struct: the struct is its C struct. Both backends emit it, though not the same
amount: the hand-written one writes a compile unit, a subprogram per function and a line
table out as bytes, because <code>.loc</code> cannot work against a file whose
instructions are <code>.byte</code> blobs, so <code>--x86 --debug</code> gives a
backtrace naming Flan files, functions and lines while <code>print x</code> says the name
is not in the current context.</p>
<p>Some things are refused by name rather than half-supported, and both cross-target <p>Some things are refused by name rather than half-supported, and both cross-target
refusals say why:</p> refusals say why:</p>