36 KiB
The duplicity audit: where the two sides meet, and where one side repeats itself
Nothing was built. This is a reading of
lib/check.ml,lib/types.ml,lib/emit.ml,runtime/flan_dyn.c,lib/prelude.ml,vendor/edn/read.flan,plan.organdspec-memory.md, with twenty-odd probe programs run through_build/default/bin/main.exe check|emit|runwhere a probe settles something a reading cannot. Every claim below is either cited to a line or reproduced from a probe whose output is quoted. Read againstdev-loopat5bd25b6. A sibling lane is implementing dyn on the x86 backend; nothing here turns on it.
The doctrine being audited: every capability has two sides, and the rule is never to repeat yourself on
the same one. The dyn side is tagged, GC-heap, heterogeneous, unannotated, and wrong at run time. The
native side is untagged, allocator-managed, annotated, and wrong at compile time. A dyn vec and a (Vec T)
are two implementations of one idea and that is correct; two implementations on the same side is the
smell. SBCL is the named model for the error half: derive what is derivable at compile time, be dynamic
where it is not.
The audit found one memory-safety hole, three silent acceptances that should be refusals, two capabilities the typed side lacks and the runtime already implements, and two same-side duplications ready to retire. Ranked questions are in §B; the duplications are in §C.
0. The first correction: the doctrine's "no annotations" is not what shipped
The doctrine says the dyn side takes no annotations. The implementation takes no parameter annotations.
The return slot is mandatory and always was — HANDOFF-dyn-m1.md:9-19 records the decision and its reason,
which is structural rather than a TODO: an optional return slot has no syntactic resolution, because
(defn f [] (Rune {.code 65}) (bar)) is both a type application and a struct literal in the same position,
and that misparse is what removed the old optional slot.
The probe confirms it. (defn f [x] (println x)):
e4.flan:1:13: println takes no type arguments — generics are milestone 5
(println x) was read as the return type. Every dyn function in this document is spelled
(defn f [x] dyn ...), with dyn written out.
Verdict: doctrine-vs-implementation mismatch, benign but load-bearing. It means the author's sketching
case — (defn settle [row col] ...) — is still not writable, which is SPIKE-INFERENCE.md's answer
arrived at from the other direction (docs/SPIKE-INFERENCE.md:17-20: that spelling "provably cannot be
given the meaning they want"). It also flattens item 3 below: neither [x $t] nor [x] frees anybody from
writing a return type, so the choice between them is never about annotation volume.
1. Absence — Option on one side, nil on the other
There is no nil. Not "two absence systems"; one, plus a documented value with no producer.
$ flan check a1.flan # (defn f [] dyn nil)
a1.flan:1:16: unknown name nil
flan_dyn_nil is defined in the runtime (runtime/flan_dyn.c:696), in the stub
(runtime/flan_dyn_stub.c:80), declared in the ABI (runtime/flan_dyn.h:35), declared to LLVM
(lib/emit.ml:3001), and exercised eleven times in test/dyn_ops.c. No path in lib/check.ml
constructs one. The dyn side's absence value is reachable from C and from nothing a Flan program can
write.
The sharpest thing in the audit is what stands in its place. box refuses Unit with this
(lib/check.ml:1436-1439):
()does not box into dyn — a value of the zero-sized type carries nothing a dyn could hold. The absent dyn value is nil, which is what anifwith no else branch answers here.
The message describes behaviour the compiler does not implement. (defn f [x] dyn (if x 1)) does not
answer nil; it is refused, by that very message:
a2.flan:1:17: () does not box into dyn — ... The absent dyn value is nil, which is what an if
with no else branch answers here
The sentence is a design note that reads as a description. A user following it writes the form it names and gets the refusal that names it.
(Option dyn) checks clean (probe a3) and is coherent: Option is a two-case data type decided
structurally in the checker (lib/check.ml:3025-3033) and never desugared, so its payload type is
unconstrained; owning walks Option e into e (lib/check.ml:451) and Dyn owns nothing, so no region
rule fires.
Verdict: undecided-needs-author, but simpler than the brief assumes. There is one reachable absence system today, not two. The conflict is prospective, and the decision is not "reconcile two systems" but "what may produce a nil, and what does one do at a typed boundary". The three coherent answers:
nilis never produced. Delete the sentence atcheck.ml:1436-1439, keep theUnitrefusal, and let(Option dyn)be the dyn side's absence too. Cheapest, and loses the Clojure feel.nilis produced and unboxes to a trap. Symmetrical with every otherneed_*:flan_dyn_need_i64of a nil traps exactly as it traps on a text.nilis produced and unboxes toNoneat an(Option T)want. The only answer that makes the two absence systems one, and the only one that puts an implicit conversion at the boundarycheck.ml:1367-1380exists to keep sharp — "nowhere else does the compiler decide a dyn is an i64 on its own", and this would be the compiler deciding a dyn is aNone.
Related, and not in the brief's ten: truthiness. (defn f [x] dyn (if x 1 2)) checks and runs, and the
condition goes through flan_dyn_need_bool (lib/check.ml:1455-1459), so a dyn if is strict-bool — a nil
or an int in the condition traps. plan.org:908-910 declares the CL-vs-Clojure truthiness question "moot
under static typing". It is not moot on the dyn side, and the strict-bool answer is currently the one
that fell out rather than the one that was chosen.
2. Equality and ordering — not two meanings, one side missing a case
Typed = is numbers and enums and nothing else. lib/types.ml:166-168:
let is_comparable = function Enum _ -> true | t -> is_numeric t
let is_equatable = is_comparable
is_equatable is a bare alias for is_comparable. The dyn = is structural over everything and is the one
dyn operation that never traps (SPIKE-DYNAMIC.md:285-287), and dyn < orders text bytewise by memcmp
(SPIKE-DYNAMIC.md:273-275).
One probe, both operators, one line apart:
$ flan check b3.flan
(defn f [x] dyn x)
(defn main [] i32 (do (println (= (f "hi") (f "hi"))) ; accepted
(println (= "hi" "hi")) ; refused
0))
b3.flan:2:64: = compares machine numbers; string has no built-in comparison (plan.org, Types)
And the generic side inverts the same way (d1):
d1.flan:2:32: this call instantiates biggest at $t = string, and string does not answer ordered?
while (defn biggest-d [a b] dyn (if (> a b) a b)) at "a" and "b" checks clean (c2).
The reflex reading is "same operator, two meanings — trap". It is the wrong reading, because it treats the
dyn answer as the anomaly. The dyn answer is right, it is cheap, and the code that implements it already
exists on the typed side too: flan_hash_str and the map's string equality
(runtime/flan_rt.c:1939, and Types.keyable says String -> true at lib/types.ml:156). So the typed
side already has a string equality good enough to key a (Map string V) — and refuses to let a program call
= on two strings.
That is same-side duplication in the making, on the typed side: keyable and is_equatable are two
answers to "does this type have an equality", they disagree about string, and one of them is a Map
implementation detail rather than a language rule.
The four-predicate ceiling is where it shows. predicate_names is exactly [ "ordered?"; "equal?"; "hashable?"; "numeric?" ] (lib/check.ml:412), and pred_entails (lib/check.ml:490-497) admits its
table is "only sound while" ordered ⊆ equatable holds — which is only true because the set is so small.
lib/prelude.ml:247-248 says the quiet part: sort-bytes exists as a hand-written sort-by because "a
[[u8]] is not [ordered?] and cannot be".
Verdict: same-side gap on the typed side, not a boundary conflict. The author's question is not "which
meaning wins" — it is whether typed = and ordered? grow the string case the runtime already implements
with memcmp. Growing them retires sort-bytes, unifies keyable with is_equatable, and makes the two
sides agree on strings at no representational cost.
3. Generics and dyn — two ways to take "any type", and they are not the same way
Both [x $t] and [x] take a value whose type is not written. The difference is where the operations on
it are checked, and it is total:
- A generic is checked at the definition. An operator over a type variable with no predicate asserting
it is refused there, at the
defn, before any call exists (lib/check.ml:976-990, andSPIKE-INFERENCE.md:26-29quotes the refusal: "+over the type variabletis refused"). The call site then re-checks the predicate against the concrete type (d1above, andpred_holdsatlib/check.ml:471-483). - A dyn parameter is checked nowhere.
(defn f [x] dyn (+ x "a"))checks clean; it traps at run time (probea6):dyn +: int and text, and it takes two numbers — (+ 1 "a").
So they overlap on exactly one function — id — and diverge on every function with an operator in it. The
generic is monomorphised and costs nothing; the dyn is tagged, heap-allocating and rooted
(emit.ml:705: a rooted alloca escapes through flan_dyn_root_push, so mem2reg cannot promote it, at every
optimisation level).
The user's rule falls out and needs no new machinery to state: write [x $t] when the operation is the
same for every type and the caller knows which; write [x] when the operation depends on the type and
nobody knows it until the value arrives. sort is the first; a config reader is the second.
Verdict: clean. Genuine duplicity, correctly placed. With the caveat that the four-predicate ceiling of §2 is what makes the generic side look weaker than it is: a generic can only express the four questions the compiler has builtins for, so every capability outside that set reads as "use dyn" when the honest answer is "the predicate does not exist yet".
4. Conditions — the refusal is right, and its absence everywhere else is the hole
signal refuses a dyn condition and a condition with a dyn field, each by name
(lib/check.ml:1976-2005). Probe f1:
f1.flan:2:31: dyn does not cross into a written type yet — the field payload of the condition Oops
is one, and a payload has to stay rooted across a handler transfer, which is milestone 2
The reason given is exact and correct: a payload crosses a handler boundary as a pointer to a frame that is still alive, and a dyn in it has to stay rooted across a transfer the root stack knows nothing about.
But an ordinary defstruct with a dyn field is accepted, and nothing roots it. Probes e2, e5, e7,
f3 all check clean:
(defstruct S [x dyn])
(defvar boxes (Vec S))
(defn stash [] () (push boxes (S {.x (vec-new dyn)})))
flan emit f3.flan shows what happens:
%dr0 = alloca i64
call void @flan_dyn_root_push(ptr %dr0)
%t1 = call i64 @flan_dyn_vec_new()
One root — the temporary, for the duration of stash's frame. It is popped at the ret. After that the
dyn vec's only reference is S.x, inside a (Vec S) whose storage belongs to an allocator the collector
never traces. The next allocation collects a live object.
Nothing in the tree refuses this. box has no arm for it — a Named type is refused only when it is being
boxed into dyn (lib/check.ml:1450-1452), not when it contains one. dyn_roots
(lib/emit.ml:710) counts nodes, not fields. dyn_sites, the --no-gc pass, walks globals' types,
parameters, the return type and flan_dyn* call nodes (lib/check.ml:7104-7136) — not struct fields, so
(defstruct S [x dyn]) passes --no-gc (probe e6, clean). That last one is vacuous today, since filling
the field needs a flan_dyn* call which the pass does catch; it will stop being vacuous the moment a typed
container boxes into dyn.
SPIKE-DYNAMIC.md:402-406 names the machinery that is missing: "a handle to a typed (Vec Enemy) would
need the collector to know which fields of Enemy are dyn, and a per-type descriptor emitted by the
compiler is how that arrives." The descriptor is M2. The field is M1 and is already writable.
Verdict: conflict, and the most urgent one in this document. Not a design conflict — an implementation hole the design already knows how to close, protected in exactly one place and open everywhere else. Whether the dyn side needs its own signal story is the second question and the easy one: typed payloads serve both sides fine, because the answer to "I want a dyn in my condition" is the same as the answer to "I want a dyn in my struct", and both wait on the same descriptor.
5. Collections — the dyn side is one third built, and edn/Value is the other two thirds
What the dyn runtime has: a vec, mutable, GC-owned (runtime/flan_dyn.c, OBJ_VEC). What it does not have:
a map, a set, and keywords. There are three object kinds — OBJ_TEXT | OBJ_VEC | OBJ_INT
(SPIKE-DYNAMIC.md:108) — and four spare box tags (SPIKE-DYNAMIC.md:57).
Keywords are not absent by oversight; they are typed-side only, at compile time. plan.org:920-923:
":space resolves at compile time against the parameter's enum type... No runtime cost." That is a
different thing from EDN's Key, which is a value that exists at run time and can be a map key nobody
declared.
Meanwhile vendor/edn/read.flan:71-80 is a complete tagged dynamic value, hand-written in typed Flan:
(defdata Value
[(Nil []) (Bool [b bool]) (Int [n i64]) (Float [x f64]) (Text [s string])
(Key [s string]) (List [items (Vec Value)]) (Set [items (Vec Value)])
(Table [entries (Map string Value)])])
with its own structural equality, value=? (read.flan:99), its own list equality (:116), its own set
equality (:128), its own table equality (:145). Every one of those is an operation flan_dyn_eq
implements (SPIKE-DYNAMIC.md:283-300).
Counted by capability rather than by implementation language, this is unambiguous: Value and the dyn
runtime both answer "a heterogeneous value that carries its type at run time". One capability, two
implementations, same side. The author's lean is right.
The price is worth stating precisely, because it is the whole of the decision:
Value has |
dyn has | what closing the gap costs |
|---|---|---|
Nil |
flan_dyn_nil, unreachable from source (§1) |
a producer and a boundary rule |
Bool/Int/Float/Text |
all four | nothing |
List |
vec | nothing |
Key |
— | a fifth object kind or a spare tag; interning if = is to stay O(1) |
Table |
— | a fourth object kind, a structural hash over tags, ordering for iteration |
Set |
— | hardest: keyable refuses Value-shaped keys (read.flan:57-62), so a dyn set cannot just be a dyn map either; Value.Set is an O(n) vec for exactly that reason and says so (read.flan:64-69) |
The M2 shape that falls out: OBJ_MAP on tag 3 as a fourth kind, keywords as a fifth, and a dyn set as a
dyn map to a unit — which works on the dyn side precisely because flan_dyn_eq has no keyable
restriction to run into. The thing Value cannot do, dyn can, and that is the argument for the retirement
rather than against it.
Note the counterpart landed on the other side while this audit was being written: the type-provider lane
has merged to dev-loop — (edn/defedn Tileset "assets/tileset.edn") derives a struct from the file at
expansion time (lib/parse.ml:1321, lib/check.ml:4702, lib/macro.ml:302, docs/BUILT.md:5810-5830),
defjson is the same over a different grammar and shares "the design and not the code"
(docs/BUILT.md:5944-5960), and macro-slurp is the prelude half (lib/prelude.ml:1822-1830).
docs/PORTING.md:633-637 marks read-edn built under that name. So the data-file capability now has both
its sides in the tree at once: defedn is the perf side (a struct, at compile time), and a dyn map with no
struct given is the dynamic side. Value is neither. It is the dynamic side, written before the dynamic
side existed.
Verdict: same-side duplication, retire — sequenced behind a dyn map and dyn keywords. Retiring it before those land would delete the only thing in the tree that can read a config file whose keys are unknown.
6. Strings — a one-way door
Typed string is a builtin ptr+len, non-owning, the same shape as a slice (lib/types.ml:22, and the
Allocator comment at :35 calls it "a builtin ptr+len"). It appears in no arm of owning
(lib/check.ml:449-459), which is the operational statement that it borrows: a (Vec string) is not
region-only, because the strings in it own nothing to release.
Dyn text is the opposite: immutable, length-prefixed, copied on construction, owned by the collector
(SPIKE-DYNAMIC.md:118-121).
Typed → dyn works and copies, via flan_dyn_from_bytes (lib/check.ml:1430). The copy is not a wart, it is
forced, and vendor/edn/read.flan:41-47 already wrote the argument for the same case: a view handed back
out of the function that owns the buffer is "garbage with nothing to say so" once the caller reads the next
file into it. A dyn value can outlive any frame, so a dyn text must own its bytes. Correct.
Dyn → typed is refused outright. Probe b5:
b5.flan:2:20: string does not cross into a written type yet
unbox has arms for i64, f64 and bool and a catch-all refusal for everything else
(lib/check.ml:1450-1474). So a program can put a string into dyn and can never get it back as a string.
It can get the bytes — flan_dyn_at answers a byte as an int (SPIKE-DYNAMIC.md:304-305) — one at a
time.
The refusal is understandable and the reason is not a representation problem: a string is ptr+len and the
text object's bytes are right there inline after the header. It is a lifetime problem, and the same one
§4 has. A string borrowed out of a GC object is valid until the next collection, and nothing in the
language says so.
Verdict: conflict. Dyn text is a one-way door today, which makes every string-shaped dyn program a
dead end at the moment it wants a typed function. Three exits: a copying (string-of d alloc) into a named
allocator (safe, explicit, the arena idiom already in the language); a borrowing one that is valid until the
next collection (fast, and needs a rule the language has no vocabulary for); or leave it refused and make
the dyn side's own string operations complete enough that nobody wants out. The first is the one that fits
spec-memory.md.
7. Allocators vs the GC — the refusal is written for the spelling nobody uses
(vec-new dyn alloc) is refused by name, with a good sentence (lib/check.ml:4094-4100):
(vec-new dyn)takes no allocator — the dyn container's storage is the dyn runtime's, which is what lets the collector find the values inside it.
But (with-allocator arena (vec-new dyn)) is accepted in silence. Probes b4 and c5 both check
clean. The ambient allocator is not consulted, not refused, not mentioned.
That is the wrong way round, because of what spec-memory.md decided. The allocator is in the calling
convention — vendor/edn/read.flan:11-18 states the consequence plainly: "read takes no allocator and
names none. It does not need to: spec-memory.md puts the allocator in the calling convention, so every
(vec-new) and (map-new) below takes the context". The explicit allocator argument is the rare
spelling; with-allocator is the idiom the whole language is written in. The compiler refuses the spelling
a user almost never writes and says nothing about the one they always do.
check.ml:627 and :1289 both record that with-allocator "rebinds a dynamic variable, so the tier a
(vec-new) will meet is not a property of where the type is written" — so the checker cannot in general
know statically which allocator is ambient. It does not have to: the question here is not which allocator,
it is whether an enclosing with-allocator is a lie when the form under it ignores it.
Verdict: conflict — silent acceptance where a refusal or a note is owed. The user's model of
with-allocator is "everything allocated in here comes from this"; a dyn construction is the one exception,
and the exception is invisible. The author's call: refuse a dyn construction lexically inside a
with-allocator, warn, or write the exception down and accept it. Refusing is probably too strong —
(with-allocator frame (edn/read bytes)) will want to hold a dyn somewhere — which makes this a
documentation-plus-note decision rather than a checker one.
8. defstruct vs defclass — the class is dyn with named slots, minus one thing
plan.org:132-190 designs defclass and it predates dyn entirely. Read its ingredient list against what
the dyn runtime now has:
defclass needs (plan.org) |
dyn already is |
|---|---|
"runtime class/shape metadata" (:157) |
the tag, and flan_dyn_tag_name |
identity, so a class instance is not a value that copies (:135) |
a flan_obj *; test_dyn's sharing mode asserts identity, not just equality (SPIKE-DYNAMIC.md:561) |
"a managed allocation strategy" (:157) |
the mark-sweep heap |
"A small tracing GC confined to class instances remains an option" (:161) |
built, and confined to exactly dyn values |
live schema change, "existing instances at their old layout until an explicit migration" (:173-176) |
a heterogeneous object has no layout to be stale |
Five of five. Building defclass as a third memory model — a third answer to identity, a third allocation
strategy, a third metadata scheme — would be same-side duplication on the managed side, and by the
doctrine it is the thing not to do.
What the dyn side genuinely lacks, and what a defclass therefore adds rather than duplicates:
- Named slots. There is no record kind; a dyn vec is positional. This is the same missing
OBJ_MAPof §5 — a class instance is a map from slot name to value with a declared slot set, which is a map with a shape check. - Dispatch.
(defmethod update ((e Enemy) dt) ...)— "exact-class, single-argument dispatch" first (plan.org:168-170). The tag is already the dispatch key; a class id in the object header is the extension. - A declared slot set at all, which is what makes the compile-time half possible: a
defclasscan report an unknown slot where a dyn map cannot.
The one thing that does not collapse into dyn, and should stay where it is: plan.org:157-160's "but
not necessarily a tracing GC — a world or session arena, pool allocation behind generational (Handle T)
values". That is the typed side's answer to identity and longevity, and it is a real second side of the
same capability, not a duplicate. A pooled (Handle Enemy) over a defstruct is the perf side; a defclass
is the managed side. Both should exist.
Verdict: undecided-needs-author, with the doctrine's answer close to forced. defclass is dyn plus
named slots plus dispatch, sharing one heap and one collector — not a third thing. The corollary, and the
part worth deciding early: plan.org:192-194 says "Do not add classes until ordinary struct, Handle,
and reload semantics are working." Under this reading the gating item changed. Classes are now gated on the
dyn side's map/record kind, which is M2 and in flight, rather than on Handle.
9. Print, hash, sort — one family already duplicated, two not yet
Printing is duplicated three ways on one side, and it is not the dyn/typed split.
runtime/flan_dyn.c:308-313 says so itself:
A text inside a structure, quoted and escaped. The same table as
flan_rt.c'sflan_escape_bytesandflan_dev.c'sflan_dev_emit_str...
Three escape tables: runtime/flan_rt.c:383 (flan_escape_bytes, with :366-372 noting it is already "the
same escape table as flan_dev_emit_str"), runtime/flan_dev.c:237 and again at :551, and
runtime/flan_dyn.c:314 (emit_escaped). SPIKE-DYNAMIC.md:329-332 calls its own "the third copy" and
leaves the instruction "If that table changes, change all three."
Two of those three are on the same side. flan_rt.c's is println's; flan_dev.c's is the inspector's, so
the REPL can parse the printed form back. That pair predates dyn and is same-side duplication that dyn has
now made three-way. The dyn copy is the one with a defence, and SPIKE-DYNAMIC.md:334-338 gives it: a
typed Vec prints as <vec> because the typed printer will not walk storage it does not own, while a dyn
vec's storage belongs to the collector and the printer is inside the runtime that owns it. That is correct
duplicity. Three tables is not.
Hashing is typed-only. flan_hash_fn, flan_hash_flat, flan_hash_str, flan_hash_combine
(runtime/flan_rt.c:1807, 1911, 1939, 1954), reached through Types.keyable and the checker's key_pair
walk. The dyn side has none, because it has no map. Verdict: clean today, and the first thing M2 gets
wrong if it is careless — a dyn map needs a structural hash over tags, and that hash must call
flan_hash_mem/flan_hash_str rather than re-derive them, or the family duplicates on the dyn side the way
printing duplicated on the typed one.
Sorting is typed-only — sort (lib/prelude.ml:277), sort-by (:304), sort-bytes (:1399).
Nothing is built on the dyn lt/le/gt/ge. sort-bytes is already a half-duplicate of sort that
exists only because [[u8]] cannot answer ordered? (lib/prelude.ml:247-248) — so it is the §2 gap,
appearing in the prelude as an extra function.
10. The SBCL lesson, concretely
SBCL's mechanism is a type lattice plus an IR1 fixpoint. ir1-optimize-cast
(src/compiler/ir1opt.lisp:3765) intersects the value's derived type with the asserted one; where the
intersection is empty the conflict is unconditional, and the node is rewritten to
%compile-time-type-error (ir1opt.lisp:3837), whose warner is %compile-time-type-error-warn
(ir1util.lisp:4351). That is a full WARNING at compile time, and the form is still emitted, so the
program that was warned about also signals at run time. What cannot be proved wrong is not dropped either:
convert-type-check (src/compiler/checkgen.lisp:518), driven by generate-type-checks (:684), lowers
the unprovable assertion to a run-time check.
Measured, not paraphrased — sbcl --noinform --non-interactive on (defun f () (+ 1 "a")) then calling it:
; caught warning:
; Constant "a" conflicts with its asserted type number.
; See also:
; The SBCL Manual, Node "Handling of Types"
;
; compilation unit finished
; caught 1 WARNING condition
#<simple-type-error expected-type: number datum: "a">
Both halves in one run: a compile-time WARNING naming the constant and the asserted type, and a
function that still exists and still signals. The warning changed the diagnostics and nothing else.
Two things carry over and one does not.
Does not carry over: the derivation. Flan has Types.equal and fits (lib/types.ml:173), and fits
is equal with one exception for Never. No lattice, no unions, no intersection, no fixpoint. "Derive the
type of every expression and intersect it with what is wanted" is not a change to check.ml; it is a
different check.ml. Anyone reading "be like SBCL" as "add type derivation" is reading a year of work into
a sentence.
Carries over, and is nearly free: refuse at the site where the pre-box type is still in hand. This is
the part worth doing. box (lib/check.ml:1418) and dyn_fold (lib/check.ml:3414) both receive the
typed expression before it is boxed — dyn_fold's own comment says "the typed side of a mixed pair is
boxed on the way in". So at (+ dyn-x "a") the second operand's type is Types.String, sitting in a
variable, at the moment the compiler decides to emit flan_dyn_add. Today that compiles (probe a6) and
traps:
dyn +: int and text, and it takes two numbers — (+ 1 "a")
The trap message is written from information the checker had. One match in dyn_fold — if an operand's
pre-box type is known and no arm of the dyn operator accepts that tag, refuse — is SBCL's empty-intersection
case exactly, with no inference, no lattice and no new pass. It catches every literal and every typed
binding flowing into a dyn operator, which is most of what a beginner writes wrong, and it catches nothing
that is genuinely dynamic, which is the whole point.
Carries over, and is the part to resist skipping: warn, and still emit. SBCL's discipline is that a compile-time type warning never changes what the program does. If Flan takes the refusal route instead, it should be a refusal only where the conflict is unconditional — a known non-numeric tag into an arithmetic operator — and never a refusal on a heuristic, because the dyn side's contract is that it runs.
One piece of evidence about how thin the typed side's own derivation is, since it bears on how much of SBCL
is even reachable from here. Fully typed (+ 1 "a") refuses like this (probe a5):
a5.flan:1:35: expected string, found the integer literal 1
It refused because the literal 1 adopted the type of the second operand and failed — not because +
rejects text. The typed side has no statement anywhere that + takes numbers; it has literal adoption and
Types.equal. That is worth knowing before promising SBCL-shaped diagnostics.
A. The two sides, as the language stands
| capability | native side | dyn side | verdict |
|---|---|---|---|
| parameter types | written | omitted | clean |
| return type | written | written (HANDOFF-dyn-m1.md:9-19) |
doctrine mismatch (§0) |
| integers | i8…i64, u8…u64 |
i64 only, 48-bit inline + box |
clean |
| floats | f32, f64 |
f64 only |
clean |
| absence | (Option T), Some/None |
nil — unreachable from source (§1) |
undecided |
| truthiness | n/a | strict bool via need_bool |
undecided (§1) |
| equality | numbers + enums (types.ml:168); string refused |
structural, never traps, text bytewise | typed side missing a case (§2) |
| ordering | ordered? = numbers + enums |
numbers + text memcmp |
same (§2) |
| map keys | keyable: ints, enums, bool, string, arrays, structs |
— no map — | typed side self-inconsistent (§2, §5) |
| "any type" | $t + {:where}, checked at the defn |
dyn, checked nowhere | clean (§3) |
| conditions | struct payloads | refused, by name (check.ml:1976-2005) |
clean; the hole is elsewhere (§4) |
| struct with a dyn field | accepted, unrooted (§4) | — | conflict |
| vec | (Vec T), allocator-owned, moves |
(vec-new dyn), GC-owned |
clean |
| map | (Map K V), open-addressed |
absent | gap (§5) |
| set | absent (Value.Set is an O(n) vec) |
absent | gap (§5) |
| keywords | compile-time enum resolution (plan.org:920) |
absent | gap (§5) |
| strings | ptr+len view, borrows, owns nothing | GC text, immutable, copies in | one-way door (§6) |
| storage | with-allocator, in the calling convention |
GC; ambient allocator silently ignored (§7) | conflict |
| identity / long-lived objects | defstruct + pooled (Handle T) — planned |
flan_obj * |
clean; defclass is the dyn side (§8) |
| dynamic data value | defedn/defjson → a struct, at compile time — landed |
dyn value; plus edn/Value |
same-side duplication (§5) |
| printing | flan_rt.c + flan_dev.c — two on one side |
flan_dyn.c |
typed side duplicated (§9) |
| hashing | flan_hash_* |
absent | clean, at risk in M2 (§9) |
| sorting | sort, sort-by, sort-bytes |
absent | typed side half-duplicated (§9) |
| compile-time type errors | Types.equal + literal adoption |
none at all | the SBCL question (§10) |
| C interop | declare/declare-c |
refused, by name (check.ml:6082) |
clean |
B. The genuine conflicts, ranked, each as one question
- A
defstructmay hold adynfield, and nothing roots it once the frame that built it returns — should the field be refused until the per-type descriptor exists, the way a condition's field already is? (§4;check.ml:1976-2005refuses one case,box'sNamedarm at:1450permits the rest;emit f3.flanshows the single root popped atret.) - What produces a
nil, and what does anildo at a typed boundary — trap, or becomeNone? (§1;check.ml:1436-1439describes a producer that does not exist.) - Does
with-allocatoraround a dyn construction refuse, warn, or stay silent? (§7; the explicit spelling is refused atcheck.ml:4096, the idiomatic one is accepted at probesb4/c5.) - Should typed
=andordered?grow thestringcase, givenkeyablealready saysstringhas an equality andflan_hash_stralready implements it? (§2;types.ml:156vstypes.ml:168.) - Can a dyn text become a typed
string— by copy into a named allocator, by borrow until the next collection, or not at all? (§6;check.ml:1450-1474has no arm.) - Is
defclassthe dyn side with named slots and dispatch, sharing one heap — or a third memory model? (§8; if the former, classes are gated on the dyn map, not onHandleasplan.org:192says.) - Is a dyn
ifstrict-bool because that was chosen, or becauseneed_boolwas the arm that existed? (§1;plan.org:908-910calls the question moot and it is not.) - Does
dyn_foldrefuse an operand whose pre-box type no arm of the operator accepts — SBCL's empty-intersection case, with no inference added? (§10; the type is in hand atcheck.ml:3414.) - What is M2's object-kind budget:
OBJ_MAP, a keyword kind, both — and does a dyn set become a dyn map to unit? (§5; four spare tags,SPIKE-DYNAMIC.md:57.) - Does the doctrine's "no annotations" concede the mandatory return slot permanently, or is
SPIKE-INFERENCE.md's return-type inference the way back? (§0.)
C. Same-side duplications to retire
vendor/edn/read.flan'sValue— a tagged dynamic value hand-written on the typed side, with its own structuralvalue=?(:99),items=?(:116),sets=?(:128),tables=?(:145). One capability, two implementations. Retire — sequenced behind a dyn map and dyn keywords (§5), not before.defedn/defjsonlanding on the perf side is what makes the pair complete once dyn's half is;Valueis the leftover middle.- The three escape tables and their number formatters —
flan_rt.c:383,flan_dev.c:237and:551,flan_dyn.c:314, with three files each instructing the reader to change the others. The dyn copy has a defence (§9). Theflan_rt.c/flan_dev.cpair does not, predates dyn, and should collapse to one exported entry point the other two call. Types.keyablevsTypes.is_equatable(types.ml:156vs:168) — two answers to "does this type have an equality", disagreeing aboutstring, one of them aMapimplementation detail wearing a language rule's name. Unify (this is question B4 by another route).sort-bytes(prelude.ml:1399) — a hand-writtensort-bythat exists only because[[u8]]cannot answerordered?. Falls out the moment B4 is answered; not worth touching on its own.
Not duplication, and listed so nobody retires it by mistake: a typed Vec printing as <vec> while a dyn
vec prints its elements (SPIKE-DYNAMIC.md:334-338); the dyn operators promoting (+ 1 2.5) while the
typed language has no implicit widening (SPIKE-DYNAMIC.md:258-262); and flan_dyn.c being its own
translation unit with a strictly one-way dependency on flan_rt.c (SPIKE-DYNAMIC.md:427-434), which is
what makes --no-gc a file-selection change rather than a dead-code-elimination wish.