diff --git a/FIX.org b/FIX.org index 4c7721b..f0153b5 100644 --- a/FIX.org +++ b/FIX.org @@ -1033,3 +1033,95 @@ inverting the condition to move the last operand into the else arm costs a [not] per operand and worse locs than it buys. What would fix it is check_if preferring the arm that is not a compiler temp when it decides which one to blame — a change in check.ml, which this lane did not own. + +* A numeric cast opens a dyn box, decided 2026-09-20 +Every numeric cast — =(f64 x)=, =(i64 x)=, =(u32 x)=, =(f32 x)=, all of +them — takes a dyn operand now. Until this, the cast arm refused it with "f64 +converts a number, found dyn", and the only thing in the language that opened +a box was a typed parameter, so a program wanting a number out of a dyn wrote +a one-line function whose parameter slot did the unboxing and called *that*. +A cast is the operator for "convert this to that"; it is the spelling that +should have worked. + +Three cases, and the middle one is the author's call. + +1. Same kind. The box holds what the cast asks for, so the cast is the unbox + and nothing else. A dyn box only ever holds an i64, an f64 or a bool among + the numbers, so =(f32 d)= on a float box unboxes to f64 and narrows, and + =(u32 d)= on an int box unboxes to i64 and narrows — each by the rule the + same cast already follows on a typed operand. + +2. Cross kind — COERCE, with a warning. The author's words were "just coerce + it with a warning". =(f64 int-box)= is 7 -> 7.0 and =(i64 float-box)= is + 2.5 -> 2, the truncation toward zero =(i64 2.5)= already does, range-check + and ArithError included. This overrides the tempting rule of matching the + parameter boundary, which traps on a kind mismatch: a cast is already a + conversion operator — =(f64 5)= converts a typed integer — so converting + across the box is the cast doing its job. The warning exists because the + box's kind was not what the program apparently expected, not because the + conversion is in doubt. + +3. A box holding a non-number traps: text, nil, keyword, vec, map — and + *bool*, which is not a special case but the parameter boundary's existing + answer mirrored. flan_dyn_need_i64 refuses a dyn holding true at a typed + i64 parameter today, and =(i64 d)= refuses it for the same reason and in + the same voice. + +** The warning is once per SITE +These casts sit in per-cell-per-frame loops — sand.flan runs at 120fps — so a +per-occurrence line is a flood and not a diagnostic. check.ml threads the +site's loc text into the runtime call and flan_dyn.c keeps a small table of +sites it has already spoken about, keyed on the loc's *bytes* rather than its +address: the two backends emit their own constants for it and neither promises +that two mentions of one site share a pointer. Sixty-four sites, and past that +it stops deduplicating rather than stops warning — the noisy failure, not the +silent one. The line is: + + flan FILE:LINE:COL: (f64 x) found a dyn holding an int, and converted it to f64 — warned once for this site + +Both builds warn, dev and release. No precedent was found making a diagnostic +of this kind dev-only: the allocation registry's notes are the one runtime +family a release build drops, and those are a *feature* being disabled, not a +warning being hushed. After the first hit this costs a tag compare and a +linear scan of a handful of entries, which is nothing. + +** How it lowers, and why that shape +check.ml's [cast_dyn] builds a branch, not a call that converts: + + (let ([s d]) + (if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1) + (u32 (flan_dyn_need_f64 s)) + (u32 (flan_dyn_need_i64 s)))) + +flan_dyn_cast_kind answers 1 for a float box and 0 for an int box, traps for +everything else, and warns when the answer disagrees with the target. Each arm +is then an ordinary [Cast] over an ordinary need — *the same node* a typed +operand of that type would have produced. + +The alternative was a coercing runtime entry point answering the finished +number, and it was rejected because =(i64 2.5)= is not a bare fptosi in this +compiler: Emit.check_cast range-checks it and signals ArithError when the +value will not fit, and lib/x86.ml does the same. A C function returning an +int64_t would have had to grow its own second opinion about range and NaN, in +a second place, for two backends — a fork of exactly the kind "Arithmetic +semantics do not fork across the two spaces" forbids. With the branch there is +nothing to keep in step, and "x86 tracks LLVM -O0" holds by construction: +programs/dyn-cast.flan prints byte-identical output on both backends, +warnings and trap included. + +The generic cast arm — =(t x)= inside a body with ={:where (numeric? $t)}= — +did NOT grow a dyn case and did not need one: the operand's type there is what +the bound admits, and numeric? does not admit dyn, so a dyn cannot reach that +arm. Pinned in test_flan.ml. + +** What this repeals +test_flan.ml's row "a keyword with no expectation converts as dyn" pinned +=(i64 :space)= as a *check* error. It is a well-typed program now and a +run-time trap instead; the row became an [accepts] saying so. That is the +whole of the behaviour change outside the new feature. + +** For the author: the shims in sand.flan can go +sand.flan defines =dyn->f64= and =dyn->u32=, one-line functions whose only +job is that their parameter slot unboxes. Every call site can now write the +cast directly — =(f64 d)=, =(u32 d)= — and the two defns deleted. Not done +here: sand.flan is the author's WIP and this lane did not touch it.