The zero-root question is the integrator's, and the handoff says which asks are open

Two decisions in this lane went against the brief and both are written down
where the next reader will meet them: the return slot stayed mandatory, so the
third state ret = None was to grow does not exist and neither does the fallout
listed for load.ml, shim.ml and cimport.ml; and the parameter rule is resolved in
Check rather than in parse.ml, because cimport passes C type names through
verbatim and POSIX's lowercase stat and timespec are writable in parameter
position, which is what makes a syntactic rule unsound rather than merely
awkward.

The open ABI point is in flan_dyn.h beside the root functions rather than only
in the handoff, because the header is what the two sides diff. A rooted slot
holding 0 is not a value: the compiler zeroes every root at entry because the
push happens before the code that fills it and possibly for a branch that never
runs, and 0 is the only pattern it can write without knowing the encoding. If
the real runtime NaN-boxes and integer zero is the zero word then this is wrong
and both sides change together.

Session.compatible needed nothing: it compares with Types.equal over the
parameters and the return, and dyn is equal to itself and to nothing else. Both
directions are pinned anyway, because this is the one place "changes signature"
covers a change the source does not spell out -- a parameter can become dyn, or
stop being dyn, by a type being declared elsewhere in the program.

@x86 128 match 0 differ 0 refused, @sanitize clean, dune test green.
This commit is contained in:
Joseph Ferano 2026-09-19 06:36:23 +07:00
parent 7c7586ebc6
commit 4aa24c0e33
3 changed files with 161 additions and 1 deletions

View File

@ -0,0 +1,124 @@
# dyn, milestone 1 — what was decided and what is left
The compiler half of dynamic-by-default. The runtime half is a sibling's, built
in parallel against `runtime/flan_dyn.h`, which is the fixed ABI and the thing
the two copies are diffed against.
## Two decisions that differ from the brief
**The return slot stays mandatory; `dyn` is written out in it.** The brief
expected `ret = None` to grow a third state meaning "unannotated", and listed
mechanical fallout in `load.ml`, `shim.ml` and `cimport.ml`. That fallout does
not exist, because the change was not made. The reason is in `parse.ml` beside
the `defn` case: an optional return slot has *no* syntactic resolution, since a
capitalised head in a list is both a type application and a struct literal —
`(defn f [] (Rune {.code 65}) (bar))` is the misparse that removed the old
optional slot, and it would come straight back. A parameter vector has no such
case, because every slot in it is a name or a type and never an expression. So
`ret = None` still means Unit and only `declare` and the shim produce it. One
token in the return position buys a decision the file paid for twice in one day.
**The parameter rule is resolved in `Check`, not in `parse.ml`.** The brief
asked for "a known type name is a type, anything else is another dyn param",
written where `parse.ml` argues its other misparse-closing decisions. That
lookup is exactly the one `parse.ml:904` records being removed for being wrong
twice in one day, and at parse time the set of type names is incomplete *by
construction* — macros generate definitions, packages are loaded later, C
headers are imported later. `cimport.ml` decides it: `named env n = tname n`
passes C type names through verbatim, so POSIX's `stat` and `timespec` are
lowercase Flan type names writable in parameter position, and no syntactic rule
("capitalised is a type") can be made sound.
So the vector is carried undecided as `Ast.pitem`s and paired in
`Check.pair_params`, after every file is loaded, every macro expanded and every
header imported. The argument is written at `parse.ml`'s `defn` case as asked.
## The residual the parent owns
The set of type names is complete at a point in time and **not across time**.
`(defn f [x y] ...)` is two dyn parameters until somebody writes
`(defstruct y ...)` — or imports a header that declares one — and then it is one
parameter of type `y`, with no edit to `f`. The signature changes underneath it,
and arity changes with it.
`Session.compatible` is where that is felt: it compares with `Types.equal` over
parameters and return, so a redefinition that changes dyn-ness is refused like
any other signature change (this falls out; it is pinned in `test_session.ml`).
But the *first* definition after such an edit is the one that changes, and
nothing warns.
## What the feature costs, and what was taken back
A parameter slot with no type used to be a syntax error. It is now a `dyn`
parameter, so **a mistyped type silently becomes an extra parameter** — the
arity changes with no diagnostic, which is the failure class `parse.ml` calls
the worst available. Two rules take most of it back, in `dyn_param_or_typo`:
- a name within one edit of a type's name gets the resolver's own "did you
mean", and
- an unknown **capitalised** name is reported as an unknown type. Not one
parameter in the corpus is capitalised, while `Form`, `Cursor` and `Vector2`
appear in these vectors constantly.
What is left uncovered is a lowercase name resembling no type: `(defn f [x
widget] ())` is two dyn parameters and nothing in the text says otherwise. That
is the feature working as specified.
**Sharp edge of the near-miss rule.** `near_miss` treats any two single-char
names as one edit apart, and it compares against every struct name in scope. So
a `(defstruct D ...)` anywhere in the program makes `(defn f [a d] ...)` a
refusal rather than two dyn parameters. The message is actionable — write the
type, or rename — but it is a refusal a user will meet without having done
anything wrong.
## Open ABI point for the integrator
**A rooted slot holding 0 is not a value, and the collector must skip it.**
This is written into `runtime/flan_dyn.h` beside the root functions, and it is
the one thing in that header decided by one side alone. Roots are pushed in the
function's entry block, before the code that fills them has run and possibly for
a branch that never runs, so the compiler zeroes every root slot and must mean
something by it — and 0 is the only pattern it can write without knowing the
encoding.
If the real runtime NaN-boxes and integer zero is the zero word, this is wrong
and the two sides need a different sentinel. Do not fix it on one side.
## Not in milestone 1, each refused by name with a location
- a typed container boxing into dyn (`(Vec i64)` → dyn): "not yet"; the
heterogeneous container is the runtime's own from `(vec-new dyn)`
- a dyn in a condition's payload, or in a field of one: milestone 2 — a payload
crosses a handler boundary and must stay rooted across the transfer
- a dyn crossing to C through `declare`/`declare-c`: it is one word and would
have passed as an integer with nothing on the other side able to ask what it
means. This one was **not** in the brief and is the dangerous one, because the
general "cannot cross to C" arm would have caught it with advice (`pass (Ptr
T)`) that is wrong for dyn.
- integer widths other than i64 and floats other than f64 unboxing from dyn:
the ABI carries one of each, and a `need_i64` plus a truncation would put an
implicit narrowing at the one boundary where the value's type was already
uncertain
- the x86 dev backend, and the JS dialect, refuse dyn entirely
## Roots: what is and is not verified
Every dyn slot and every dyn-producing runtime call is rooted, pushed in the
entry block and popped at every `ret` — which is the funnel all five exits pass
through, the transfer landing block included. Pushes and pops balance **by
construction**: `dyn_roots` counts before emission, the slots are minted from
that count, and `dyn_tmp` only hands them out.
**The stub verifies none of this.** `flan_dyn_stub.c` mallocs and never frees,
so a program with entirely wrong root discipline passes every test that runs
against it. What is checked instead is the IR: an early-return function pops on
both paths, and a function with a defer pops on the transfer path. When the real
collector lands, that is the area to re-examine first.
Cost: a rooted alloca has its address escape through `flan_dyn_root_push`, so
mem2reg cannot promote it. Every dyn local and every dyn temporary is a real
stack slot with a real store, at every optimisation level. That is inherent to a
precise collector with an address-registration ABI rather than stack maps.
A function with no dyn emits nothing — no push, no pop, not a `pop(0)` — which
is what makes `--no-gc` byte-identity hold.

View File

@ -100,7 +100,29 @@ int32_t flan_dyn_need_bool(flan_dyn v);
* one [flan_dyn_root_pop] per scope carrying the count the scope pushed.
*
* The address is registered, not the word: the slot is written again while it
* is rooted, and a collection in between has to see the current value. */
* is rooted, and a collection in between has to see the current value.
*
* OPEN POINT FOR THE INTEGRATOR
*
* A ROOTED SLOT HOLDING 0 IS NOT A VALUE, AND THE COLLECTOR MUST SKIP IT.
*
* This is a constraint the compiler puts on the encoding, and it is the one
* thing in this header that was decided by one side alone. The reason it is
* forced: roots are pushed in the function's entry block, before the code that
* fills them has run, and a slot may belong to a branch that never runs at
* all. So the compiler zeroes every root slot at entry and has to mean
* something by it, and 0 is the only bit pattern it can write without knowing
* how values are encoded. Globals get the same treatment for free, from BSS.
*
* If the real runtime's encoding makes 0 a legitimate value a NaN-boxing
* scheme where integer zero is the zero word is the obvious way this breaks
* then this is wrong and the two sides need a different empty sentinel, which
* is a change to this header that both make together. Do not resolve it by
* changing one side.
*
* The root stack is strictly LIFO and the pops say how many, because there is
* no way to read its depth. Globals are pushed once, in main, before anything
* else and never popped. */
void flan_dyn_root_push(flan_dyn *slot);
void flan_dyn_root_pop(int64_t n);

View File

@ -51,6 +51,20 @@ let () =
refuses "a changed arity"
"(defn outer [a i64 b i64] i64 (bump))"
"changes signature";
(* Dyn-ness is part of a signature like anything else, and this falls out of
[compatible] rather than being added to it: the comparison is
[Types.equal] over the parameters and the return, and dyn is equal to
itself and to nothing else. Pinned anyway, because it is the one place the
word "signature" covers a change the source does not spell out the
return type here went from [i64] to [dyn] by being written differently,
and a parameter can change the same way by a *type* being declared
elsewhere in the program. *)
refuses "a return type that became dyn"
"(defn outer [] dyn (bump))"
"changes signature";
refuses "a parameter that became dyn"
"(defn outer [x] i64 (bump))"
"changes signature";
(* The storage exists and has a shape: reusing it reads at the wrong offsets,
and replacing it discards the state the reload exists to preserve. *)
refuses "a retyped global"