34 Commits

Author SHA1 Message Date
Joseph Ferano
58d5ccda94 Typed float % on x86: the same fmod LLVM calls
There is no SSE remainder instruction, and LLVM does not invent one: at -O0
it lowers frem to fmod or fmodf. The backend now calls those two symbols
rather than refusing the operator, which is agreement by construction rather
than a second hand-written identity that would have to get every rounding,
every signed zero and every infinity right on its own.

Rem was the only gap. emit.ml's float surface is Add, Sub, Mul, Div, Rem and
the six comparisons; x86 had everything but Rem, and its comparisons already
build LLVM's ordered predicates out of ucomis, setcc and setnp.

math3.flan grows the operator spelling beside the fmod-f32/fmod-f64 calls it
already had, through globals so the pair is not folded before either backend
sees an operator. FIX.org records the ruling the fix came from.
2026-09-20 11:21:25 +07:00
bddc8fc5dd and hands back its deciding operand too, and both locs get sharper
(and a b) desugared to (if a b false), so it answered the last operand
only when every operand was truthy; a falsey one came back as a bare
false, where Clojure answers the falsey operand itself. It now uses the
same expansion or got in ad0f1fb -- (let [t a] (if t b t)) against or's
(let [t a] (if t t b)) -- so the operand that decided the form is the
answer, and the test is still evaluated exactly once.

The temp binding and its if now carry the operand's own loc rather than
the whole form's, which the or fix had lost: (or (vec-new i32) v) blamed
the enclosing form at 3:13 and now points at 3:18, the operand, and and's
second operand gained the same precision.

The parse pins in test_flan.ml now tie the bound name to the temp the if
tests and the bound value to the first operand, so a desugaring that
dropped the temp and wrote the operand into the arm twice no longer
passes; and has its own pin. dyn-if-truthy.flan grows the falsey-nil and
falsey-false answers, 0 and "" as truthy operands, one- and zero-operand
forms, and a printing operand that proves both the short circuit and the
single evaluation.

One behaviour that used to compile changed: with both arms of the
desugared if now holding real values, (and dyn-value typed-bool) unifies
on the typed arm and a non-bool dyn decider traps at the strict bool
boundary -- (and (box nil) some-bool) printed false and now traps, the
mirror of what (or false (box "s")) already did on dev-loop. Recorded in
FIX.org as the author's call on how check_if should join a bool arm and a
dyn arm.
2026-09-20 10:36:05 +07:00
b5f17826fe The queue records item 7's review pass: the or fix, and two things left alone
FIX.org's item 7 gets the full account of the review that followed
landing: or's fix (ad0f1fb), named there rather than left as "review
pass"; the keyword-condition diagnostic given up on purpose, the
author's call; and the exponential retry on a chain of nested not that
does not type-check, looked at and left alone since a cheaper retry
would cost message fidelity on a compound condition wrapping a literal,
not just speed.
2026-09-20 09:57:17 +07:00
d7070501e4 The queue marks item 7 landed, and names the or asymmetry it turned up
dyn if's truthiness rule reached when, cond, and, or, not and while
through one checker funnel, and turned up something nobody had decided:
and's short-circuit answer already carries a non-bool dyn value through,
Clojure-style, but or's does not, because its sentinel occupies the arm
check_if types first. Written down here rather than fixed, since the
queue's items get decided one at a time and this one is not yet.
2026-09-20 09:16:21 +07:00
c71ae8020c M2 item 4 landed at 3c1fb1b 2026-09-20 07:20:45 +07:00
87aeb7b0da Six defects back from review, fixed rather than reworded around
F3: the unknown-struct-vs-function message overgeneralized a one-case
parser quirk into a language rule that does not exist. (g {:a 1}) compiles
fine -- only the empty map is stolen by is_struct_map's Map [] -> true.
The message now names {} specifically and says why: it is read as the
zero-field struct literal, not "a map literal cannot be passed as an
argument".

F2: the ordering refusal named machine numbers only, when Types.is_comparable
also admits enums and enum-compare.flan orders one with (< k :mid). Both
messages -- equality and ordering -- now name every type each actually
covers.

F1 and F5, together, since both live in the same parse.ml arm: the
rest = [] carve-out that let a single-form dyn map body through
reintroduced the exact bug the earlier fix was for. (defn mx [a $t b $t]
$t {:where (ordered? $t)}) -- a :where clause with nothing after it, what
a moved closing paren produces -- no longer matched, fell through to expr,
and printed "unknown function ordered?" from inside what was meant as a
predicate. A :where map is peeled unconditionally again, whether or not
anything follows it; every other keyword, plus the empty map and any
non-keyword key, is still peeled only when the body has more after it, so
a real single-form dyn map body is still left alone. The comment
justifying the discarded-map refusal claimed a map literal has no side
effects; it does -- {:a (println "hi")} prints, confirmed by running it --
so the reasoning now names the actual problem, a value going unused, not
a false claim about purity.

Closing that F1 hole this way opened two more, both traced by rebuilding
the pre-fix parse.ml and diffing real compiler output rather than
reasoning about it: {.x 1} at a defn body's head used to get its own
message, "a bare map is not an expression", and briefly started getting
"a constraint map is keyword/value pairs" instead, because the broadened
guard no longer required a keyword and started catching the struct-field
shape too. Excluded explicitly, with a comment saying why, so expr's
dedicated diagnostic fires again. And (defn main [] i32 {} 0) and
(defn main [] i32 {"a" 1} 0) -- the empty map and the non-keyword-keyed
map, the two siblings a keyword-only guard could never have caught --
now get their own refusals alongside the keyword case, each pinned with
a rejects_check row, along with the where-with-no-body case and the two
legitimate single-form bodies that must keep compiling.

F6: the acceptance runner's tail already caught every failure on every
path through the binary -- there is no skip branch that bypasses it, and
the no-clang branch runs zero rows -- so last round's at_exit guard and
the FIX.org note both overstated what was broken. Both now say what was
actually true: the exit status was already trustworthy, the guard is
insurance against a future case leaving past the tail instead of through
it, and the other nine test binaries were already sound the same way.
The guard also had a real bug of its own: forcing exit 1 whenever
failures was nonzero would stomp the watchdog's own exit 2 if a hang
followed a few already-failed rows, since Stdlib.exit runs at_exit
handlers LIFO. watchdog.ml now flags when it is the one unwinding, and
test_acceptance.ml's guard defers to it -- shared state for a single
caller, justified by there being no other way for one at_exit handler to
know a sibling handler is already mid-exit with a code of its own to
protect.

Verified every case in this commit by compiling and, where it mattered,
running the actual program -- not by inspecting the arm and assuming.
dune test --force: exit 0, clean grep for FAIL and Fatal error.
2026-09-19 21:35:08 +07:00
c76a507d3b The batch from two reviews plus the acceptance exit code, item by item
Constraints parsing peeled a body-leading map only when its first key was
literally :where; any other keyword fell through to the body, so a typo'd
key surfaced as a baffling error from inside what was meant as a predicate
and a stray map at body start compiled away silently. Any keyword-first map
is read as a constraint map now, but only when something follows it in the
body — a single-form map body is a real dyn value and not a discarded
statement, so that case is left alone.

An empty map literal still parses as a struct literal, (P {}) still meaning
the zero struct for a real struct name — the parser has no symbol table to
tell (take {}) apart from it at that point. check.ml now catches the case
where the name turns out to be a known function instead and says so, rather
than "unknown struct take".

flan_dyn.c's tag comment still said 6 and 7 were free; keywords and maps
took 4 and a kind field under BOX_OBJ, not new top-level tags, so 5, 6 and 7
are what is actually open for the interop handle. NEXT.md and json.flan both
still pointed at test/programs/arena-edn.flan, gone since edn/read stopped
taking an allocator; both now point at what replaced it.

flan_rt.c's flan_str_eq comment claimed the empty string literal was a
hypothetical null-pointer string; it isn't, its address is an interned
symbol's. The real case the zero-length guard exists for is a zero-length
container converted to a string. check.ml's ordering refusal said a string
has no comparison at all, which stopped being true when typed = and !=
grew strings in daed039 — split the message so an equality refusal and an
ordering refusal say the right noun, and updated the pinned rejects_check
rows to match. string-eq.flan gained the row the fast path most wants
tested, a slice against the prefix it was cut from sharing a base pointer at
different lengths, plus a != row at equal length with differing bytes;
acceptance now carries the real output, captured by running the program on
all three lanes. x86.ml's xor-1 comment now names the 0/1 return contract as
a requirement flan_str_eq must hold, not an incidental fact. SPIKE-DUPLICITY
now says plainly that its equality-and-ordering argument landed in daed039
and marks its transcript as the historical state that argument was made
against. FIX.org ticks M2 queue item 5.

And the acceptance runner: the tail check that turns a nonzero failure count
into exit 1 was already there and already fired — a fresh build with one row
broken already exited 1 before anything here changed. What wasn't proven is
that every path through the file's clang/wasmtime/raylib/lldb probes still
reaches that tail rather than skipping past rows that already failed. An
at_exit guard now closes that class regardless of which path the process
leaves by, flushing stdout first so a failing run's FAIL lines survive
Unix._exit rather than being dropped from the buffer. Verified both
directions with a deliberately broken row: dune test exits nonzero and the
log still carries the FAIL line and the failure count; restored, the same
run is exit 0 with nothing printed but green summaries. The other test
binaries were checked for the same gap and none have it — each gates its
own exit on a single failures ref that the tail already reads.
2026-09-19 21:18:57 +07:00
f360531918 Merge branch 'worktree-agent-a5df4c0409bf9d7c4' into dev-loop 2026-09-19 21:13:29 +07:00
624b595869 The two models get named, and the gap handler-case fills gets written down
The dynamic paths follow Clojure and Common Lisp, the static paths follow
Odin, and the ML share is what neither supplies. Where the two Lisps
disagree the question is which one the rest of Flan already agrees with:
conditions say Common Lisp, maps and keywords say Clojure. The missing
unwinding handler is recorded alongside it, since a caller that wanted a
default had nothing to write once read-file stopped returning an Option.
2026-09-19 20:54:11 +07:00
a251dca67f handler-case is a handler-bind plus a transfer, and nothing more
The unwinding handler, which spec-conditions.md named and left unwritten while
it asked whether the thing should be a macro over the two operators that were
already here. It should. (handler-case B [(T [c] A)]) is checked as
(restart-case (handler-bind [(T [c] (invoke-restart 'R c))] B) (R [c T] A))
with R a name the form makes up for itself, which is Common Lisp's own
definition of the operator and means neither backend needed a line.

What that buys is not economy, it is the correctness of the parts nobody can
see. The defers between the signal and the form run, and the allocator a
with-allocator rebound is put back, because a transfer already does both for
every frame it leaves. The body and every clause agree on one type, because a
restart-case's body and clauses already do, and a clause that disagrees is
refused with the same message an if with disagreeing arms gets. A condition no
clause lists installs no frame that matches it and goes on outward untouched.
A clause sees the establishing function's locals, which a handler-bind clause
cannot, because a restart clause runs where it was written.

The body comes first and the clauses after it, the opposite of handler-bind's
order: one reads as something put around a body and the other as a body with
answers hung off the end of it. The restart the two halves meet over is named
after the function and numbered within it, and it has to be unique per form,
because two nested handler-cases sharing a name would have the inner frame
shadow the outer one and land a condition at the wrong place.

The refusals name handler-case rather than the machinery underneath it, which
is why check_handler_bind and the restart clauses now take the word the reader
wrote. A break loop entered under a handler-case still lists the made-up
restart, and taking it there is refused loudly rather than answered wrongly;
hiding it would mean a field in a frame layout spelled out in three files.

The survey program runs the same under LLVM, at -O0 and under --x86: normal
completion, a caught condition, one nobody listed passing through with the body
carrying on, both defers on the way out, the two nestings against handler-bind,
a clause that signals and is caught outside the form it belongs to, and a
with-allocator whose restore is on the transfer path.
2026-09-19 20:53:31 +07:00
d3a9c790f0 The queue learns what today decided: descriptors, sweeps, and a parked JS hole
Item 3's descriptor is its own thing rather than the slice type reused, item
8 is settled with both spellings intact, and the sweep policy now says a lane
runs the fast check while the surveys are paid for once across several lanes.
The JS dialect's wrong answer on string equality is written down where the
next reader will find it, along with the surface-syntax discussion that ended
in deferral.
2026-09-19 20:17:18 +07:00
5f55c177ba The M2 queue, decided item by item 2026-09-19 15:46:19 +07:00
5bd25b61df Where the session stopped, and what is still in the air 2026-09-19 10:59:29 +07:00
a0f37e72a2 The ! suffix retires: a mutator is named for what it does, not marked
The !-means-mutates convention distinguished nothing — there is no
immutable counterpart to contrast with — so every mutating name drops
the mark: sort, sort-by, sort-bytes, swap, reverse, append, append-i64,
append-f64, encode-rune, split-next, map-remove, map-next, and the test
helpers beside them. Two could not simply shed it: map! is map-in-place,
because map is the into transform's word and means the non-mutating
thing; put! is put-at, because put is the Map builtin. The ?-means-asks
convention stays. Dated records keep the old spellings; watch.clj's
reset-spies! and the other Clojure names are not ours to rename.
2026-09-19 05:21:02 +07:00
5c228d35dc Merge: the command is flan, and it knows what buffer you are in 2026-09-19 02:20:29 +07:00
6960a7e929 Merge: sets tokenize, and the reader belongs to the package 2026-09-19 02:19:39 +07:00
d5c969d58b Merge: a parked program answers an expression on the thread that is already asleep 2026-09-19 02:18:44 +07:00
d9404bb34a The client drops -dev- from its names, and starts the buffer you are in
`-dev-` was in every Emacs symbol this client owns and meant nothing to anyone
typing one: the daemon is `flan dev` at a shell, but from inside Emacs there is
no other kind of connection to distinguish it from. `M-x flan-dev` is now
`M-x flan`, `flan-dev-quit` is `flan-quit`, the private prefix `flan-dev--` is
`flan--`, and every defcustom follows — ninety-odd symbols, with the two files
renamed to emacs/flan.el and emacs/test-flan.el so the file names say the same
thing as the symbols in them.

No aliases. Renaming a defcustom breaks a config that names it and there is no
way around that; the repo has no precedent for softening one, and an alias left
behind is what keeps a rename from finishing. MANUAL.md says the old names are
gone and how to fix a config, which is the whole of the migration path.

Three strings are not symbols and keep their spelling: `.flan-dev.sock`, which
bin/main.ml writes and which a renamed variable searching for a renamed file
would simply never find; and the two buffer names `*flan-dev*` and ` *flan-dev*`,
which name the `flan dev` subcommand's own output rather than anything in elisp.
`flan dev` with a space is the CLI and is untouched everywhere.

The entry point also stops asking a question it already has the answer to. From
a buffer visiting a .flan file it starts that file; from anywhere else it reads
one from the minibuffer as before; `C-u` reads one either way, which is how you
start a second program without leaving the first. The current buffer is still
the only source of the default — the bug where a previous project won over the
buffer you were in was fixed by removing `flan--file` from that position, and
nothing here puts it back.

Four checks on the `interactive' form, evaluated on its own rather than by
calling the command, because calling it would build and launch a program and
the question is only which file the form arrives at and whether it had to ask.
A fifth asserts that nothing answers to the old names. test/test_emacs.ml loads
the test file by path and test/test_session.ml names the client file in a
comment, so the rename reaches those two lines; nothing else outside emacs/ and
the docs moved. Verified by byte-compiling every file
clean and by `dune test` and `@page`.
2026-09-18 23:20:26 +07:00
b206e9a9a4 An expression evaluated against a parked program runs on the parked thread
The park waited on one flag and could do one thing, so C-x C-e on (+ 1 1) was
refused for want of a frame boundary — an expression that needs nothing from
the program, in a process holding every global the run left.

It waits on two now. A re-run leaves the park; a wake drains the agent's ring
and waits again, with the state still PARKED, which is what makes running the
thunk there exactly as safe as running it at a frame boundary: while parked
there is no concurrency to be unsafe against. The break loop is the precedent
and CL's spawned worker is deliberately not copied.

A thunk that stops now stops on a parked thread, so the restart ops refuse on
whether a break is engaged rather than on the state, and a paused expression
against the park is resumable.
2026-09-18 23:11:12 +07:00
cc2cfa175b Sets are read, and the reader that answers a Value is the package's
The tokenizer refused #{} because "it needs a hash set to even
represent" — which is a claim about a reader, and a tokenizer represents
nothing. #{ now pushes } on the same balance stack { does, there is one
new token kind and no new closer, and err-set is gone rather than kept
with a message it no longer earns. skip-value needed nothing: it is
written against the depth and not against the kinds.

The dynamic reader moves out of test/programs/arena-edn.flan and into
vendor/edn/read.flan as (edn/read bytes), answering an (Option Value)
against whichever allocator the caller bound. Two decisions are written
down where they are made:

  * a set is a Value.Set holding a deduplicated (Vec Value), because
    (Map Value bool) does not typecheck — keyable refuses a key holding
    a Vec or a Map — and restricting elements to keyable Values would
    refuse #{[0 0] [1 0]}, which is the file this was built for. Insert
    is O(n) against a structural value=?, so building the tileset's 54
    pairs is 1458 comparisons, once.
  * a Value copies every string into the allocator where a Token stays
    a view. A view handed back out of the function that owns the buffer
    is a dangling pointer, and free-all would not even take it. Odin's
    json parser clones for the same reason.

An imported defdata was a refusal in load.ml — "not implemented yet
(milestone 4)" — and it had to go first. It is the type's name plus the
Type. half of a constructor symbol, which arrives as a Var node when the
case has no fields and a Struct node when it has; a match pattern needed
nothing, because a case resolves against the scrutinee's type and was
never a top-level name. programs/pkg-data.flan is that on its own.

programs/edn-read.flan reads assets/edn/tileset.edn, which is the
editor's real output: :texture-path and a :selected-cells of 54 integer
pairs, with no type declared for any of it. It also overwrites the
source buffer in place after reading and prints the document back, which
is the copy contract asserted rather than described.
2026-09-18 22:50:54 +07:00
5d1ea9dede A daemon whose editor was killed no longer waits for it for ever
[close] was the only way out of the accept loop that anybody ever took, and
an editor that is killed rather than quit never sends it. Four of the eight
orphans found on the author's machine were that.

The signal is the socket, not a new op: Emacs opens one connection for the
whole session and drops it only on paths that send [close] first, so an
editor left open overnight is an editor still attached and the grace cannot
accumulate under it. Armed only once a client has been there, so a daemon
still waiting for its first one is untouched. Parked sessions get the short
clock and live ones six times it, because a parked program is invisible and
a running one is a window somebody may be looking at.
2026-09-18 22:43:32 +07:00
233bb780b0 The last lane landed after all 2026-09-18 13:56:15 +07:00
da2280cceb Where the evening stopped 2026-09-18 13:51:58 +07:00
7187f58ff0 The day is closed out where it is kept 2026-09-18 12:59:15 +07:00
0faacb3e57 The spec records the second round: two containers, everything copies, five words in a header 2026-09-18 12:38:26 +07:00
2b9f271cc3 The repeal is written down where the rules were: spec, BUILT, NEXT, the ledger, FIX 2026-09-18 07:46:37 +07:00
1f75744b06 Four lanes in, and what is left is the author's 2026-09-17 23:25:10 +07:00
c0ccbe2df2 The runtime stops trusting multiplication, sharing one buffer, and keeping an unread word ambiguous 2026-09-17 23:14:28 +07:00
9f85139f1f An Option has no fields to take the address of, and two rows now say so
FIX.org carried Addr(Pfield ...) on an Option as a hole in both backends. It is
not reachable from the language: a field access goes through struct_target,
which admits a struct or a pointer to one and refuses everything else by name
with a location, so (addr (.x o)) is refused at the field and never reaches a
place. The node that failed was one the compiler built for itself.

The refusal is pinned on the bare field and on the address of one, and FIX.org
now records the finding, including the asymmetry that stays: the x86 backend
lays out an Option's tag and value as fields and the LLVM backend does not.
Neither path is reachable, so matching them would be untestable code written to
balance a road nobody drives on.
2026-09-17 22:59:03 +07:00
8a430bb50c The review has four lanes, and the JS backend has its references 2026-09-17 21:43:58 +07:00
1ad146fdbf Where the list stands, and the branches kept off it 2026-09-17 20:38:08 +07:00
d0d94a05f6 The refusal was about teardown, and an arena has none 2026-09-17 19:44:00 +07:00
9e80147527 What landed, and the one thing re-run cannot do 2026-09-17 19:11:08 +07:00
798c852934 Where a program starts, how a big array clears, and which key folds
flan-dev's start command proposed the last program it had started, so
invoking it from a fresh project's buffer offered the previous project's
file. It now proposes the buffer it was called from; restarting the
previous program is what flan-dev-restart-program is for.

Zeroing a fixed array wrote one typed store per element. Above 64 bytes
that becomes a memset, which LLVM can lower as a bulk clear; below it the
inline stores are still cheaper than a call.

Outline's minor-mode map owned TAB in the lowering buffer, so the folding
keys that buffer defines never ran. A buffer-local overriding map gives
them back without touching Outline anywhere else.

FIX.org collects the rough edges found while using the dev loop.
2026-09-17 18:08:51 +07:00