The numeric casts open dyn boxes, coercing across kinds with one warning per site

This commit is contained in:
Joseph Ferano 2026-09-20 14:11:04 +07:00
commit 6baed9a0ba
9 changed files with 503 additions and 3 deletions

92
FIX.org
View File

@ -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.

View File

@ -1674,6 +1674,69 @@ let unbox loc (want : Types.t) (e : Tast.expr) : Tast.expr =
then "f64" else "i64"))
| _ -> no_dyn_yet loc ~into:false want ""
(* ── A numeric cast written on a dyn, FIX.org 2026-09-20 ───────────────
*
[(f64 d)] where [d] is dyn. Until this, the only place in the language
that opened a box was a typed parameter, which is why a program that
wanted a number out of a dyn had to define a one-line function whose
parameter slot did the unboxing and call *that*. A cast is already the
operator for "convert this to that", so it is the spelling that should
have worked, and now does.
What is built is a branch on the box's tag, 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))))
Each arm is an ordinary [Cast] over an ordinary [need], so the conversion
is *the same node* a typed operand of that type would have produced.
That is the point of this shape rather than a coercing runtime entry point
that answers the finished number: [(i64 2.5)] is not a bare [fptosi] in
this compiler [Emit.check_cast] range-checks it first and signals
ArithError when the value will not fit, and the x86 backend does the same
so 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.
Here there is nothing to keep in step: [(i64 float-box)] *is* [(i64 x)]
with an unbox in front of it, which is exactly what the semantics say.
[need_f64] and [need_i64] are the trapping entry points, and neither can
trap here: each is reached only on the arm where the tag has already been
read as its own. The trap that can happen is the runtime's, for a box
holding a non-number, and [flan_dyn_cast_kind] owns that sentence bool
included, which is what [flan_dyn_need_i64] already does with a bool at a
typed parameter. The cross-kind case does not trap: it converts and warns
once for the site, the author's call, recorded in FIX.org.
The slot exists because the value is read three times once for the tag,
once on whichever arm runs and the argument may be an arbitrary
expression. [unbox_option] above builds the same shape for the same
reason. *)
let cast_dyn ctx loc (target : Types.t) (got : Tast.expr) : Tast.expr =
let s = fresh_slot ctx Types.Dyn in
let sv = mk loc Types.Dyn (Tast.Local s) in
let want_float = match target with Types.Float _ -> 1 | _ -> 0 in
let kind =
rt loc (Types.Int Types.I32) "flan_dyn_cast_kind"
[ sv; here loc;
mk loc Types.String (Tast.Str (Types.to_string target));
mk loc (Types.Int Types.I32) (Tast.Int (Int64.of_int want_float, Types.I32)) ]
in
let is_float =
mk loc Types.Bool
(Tast.Prim (Tast.Eq,
[ kind;
mk loc (Types.Int Types.I32) (Tast.Int (1L, Types.I32)) ]))
in
let arm sym ty = widen loc target (rt loc ty sym [ sv ]) in
mk loc target
(Tast.Let ([ (s, got) ],
[ mk loc target
(Tast.If (is_float,
arm "flan_dyn_need_f64" dyn_f64,
arm "flan_dyn_need_i64" dyn_i64)) ]))
(* nil is written [nil] and nothing else produces it, so this is the whole of
"the checker can see a nil reaching here" a name, not a dataflow fact.
There is no propagation through a [let] or a call in this checker (see
@ -6123,9 +6186,16 @@ and named_call ctx ~want loc name args =
let a = check ctx (List.hd args) in
(match a.Tast.ty with
| Types.Enum _ -> ()
(* A dyn opens here — [cast_dyn], FIX.org 2026-09-20. Only on this arm:
the generic one above casts to a type *variable*, whose [where] clause
says the operand is numeric, and a dyn is not what a [numeric?] bound
admits. *)
| Types.Dyn -> ()
| t when Types.is_numeric t -> ()
| t -> fail loc "%s converts a number, found %s" name (Types.to_string t));
prim (Tast.Cast target) target [ a ]
(match a.Tast.ty with
| Types.Dyn -> cast_dyn ctx loc target a
| _ -> prim (Tast.Cast target) target [ a ])
(* ── ordinary calls ────────────────────────────────────────────── *)
(* A local or a parameter holding a function value, called by the name it is

View File

@ -3653,6 +3653,11 @@ declare void @flan_dyn_print(i64)
declare i64 @flan_dyn_need_i64(i64)
declare double @flan_dyn_need_f64(i64)
declare i32 @flan_dyn_need_bool(i64)
; A numeric cast written on a dyn answers which numeric tag the box holds;
; check.ml's [cast_dyn] branches on it and each arm is an ordinary need plus
; the ordinary cast. The two slices are the site's location and the target's
; name, each crossing as ptr+len.
declare i32 @flan_dyn_cast_kind(i64, ptr, i64, ptr, i64, i32)
; nil <-> None at an (Option T) boundary, and (Some nil)'s run-time half
; M2 queue item 4, check.ml's [box_option]/[unbox_option] and the [Some]
; builtin.

View File

@ -1139,6 +1139,92 @@ uint8_t flan_dyn_need_bool(flan_dyn v) {
return (uint8_t)(dyn_payload(v) ? 1 : 0);
}
/* ── A numeric cast opening a box, FIX.org 2026-09-20 ───────────────────
*
* [(f64 d)] on a dyn. The *conversion* is not done here: [check.ml] lowers
* such a cast to a branch on this function's answer, and each arm is the
* ordinary [flan_dyn_need_i64]/[flan_dyn_need_f64] followed by the cast the
* emitter already emits for a typed argument of that type. So this function
* decides one thing which of the two numeric tags the box holds and
* everything about the arithmetic (the fptosi range check, NaN, the
* narrowing rule) stays where it already was, identical in both backends and
* identical to the typed spelling of the same cast.
*
* Answers 1 for a float box, 0 for an int box. Every other tag traps, with
* the same [trap1] sentence the typed boundary's own refusals use bool
* included, which mirrors [flan_dyn_need_i64] refusing a bool today rather
* than inventing a new rule for casts.
*
* [want_float] is what the *target* type is: 1 for f32/f64, 0 for the
* integer widths. When it disagrees with the box, the cast still happens
* the author's call, "just coerce it with a warning" and the warning below
* is the whole of what the disagreement costs. 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.
*
* Once per *site*, not per value. These casts sit in per-cell-per-frame
* loops sand.flan runs at 120fps and a per-occurrence line would be a
* flood rather than a diagnostic. The site is the [loc] text [check.ml]
* passes in, and the table below is keyed on its *bytes* rather than its
* address: the two backends emit their own constants for it and neither
* promises that two mentions of one site share one pointer.
*
* The table is fixed and small because it is only ever as large as the
* number of cross-kind cast sites a program has, which is a handful in the
* programs this was written for. A program with more than [SITE_MAX] of them
* stops deduplicating for the overflow it still warns, every time, which
* is the noisy failure rather than the silent one. Not thread-safe, and
* deliberately: a duplicated or dropped line under a race is a diagnostic
* that came out twice, and the alternative is a lock on a path that runs per
* cast in a frame loop. */
#define SITE_MAX 64
static struct { const uint8_t *ptr; int64_t len; } warned_sites[SITE_MAX];
static int warned_count;
static int site_first_time(const uint8_t *loc, int64_t loc_len) {
for (int i = 0; i < warned_count; i++)
if (warned_sites[i].len == loc_len &&
memcmp(warned_sites[i].ptr, loc, (size_t)loc_len) == 0)
return 0;
if (warned_count < SITE_MAX) {
warned_sites[warned_count].ptr = loc;
warned_sites[warned_count].len = loc_len;
warned_count++;
}
return 1;
}
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
const uint8_t *target, int64_t target_len,
int32_t want_float) {
int32_t tag = flan_dyn_tag(v);
if (tag != FLAN_DYN_TAG_INT && tag != FLAN_DYN_TAG_FLOAT) {
/* [trap1] takes the operation as a C string and the target is a Flan
* slice, so it is copied out. Every cast name is two or three bytes; the
* clamp is for a caller this file cannot see. */
char name[8];
size_t n = (size_t)target_len < sizeof name - 1 ? (size_t)target_len
: sizeof name - 1;
memcpy(name, target, n);
name[n] = '\0';
trap1(TYPE_TRAP, name, "a number was wanted", v);
}
int32_t is_float = tag == FLAN_DYN_TAG_FLOAT ? 1 : 0;
if (is_float != (want_float ? 1 : 0) && site_first_time(loc, loc_len)) {
fflush(stdout);
fprintf(stderr,
"flan %.*s: (%.*s x) found a dyn holding %s, and converted it to "
"%.*s — warned once for this site\n",
(int)loc_len, (const char *)loc, (int)target_len,
(const char *)target, is_float ? "a float" : "an int",
(int)target_len, (const char *)target);
}
return is_float;
}
/* nil <-> None at an (Option T) boundary. Cannot trap — every dyn value
* answers this one way or the other. */
int32_t flan_dyn_is_nil(flan_dyn v) {

View File

@ -144,6 +144,21 @@ int64_t flan_dyn_need_i64(flan_dyn v);
double flan_dyn_need_f64(flan_dyn v);
uint8_t flan_dyn_need_bool(flan_dyn v);
/* A numeric cast written on a dyn — [(f64 d)], [(u32 d)] — FIX.org
* 2026-09-20. Unlike the parameter boundary above this one coerces: it
* answers which numeric tag the box holds (1 float, 0 int) and the caller
* branches, so the conversion itself is the cast the compiler already emits
* for a typed operand of that tag. A box holding anything else traps, bool
* included, exactly as [flan_dyn_need_i64] refuses one.
*
* [want_float] says what the target type is, and a disagreement writes one
* line to stderr once per [loc], not once per value, because these casts
* run inside frame loops. [loc] and [target] are Flan slices: pointer and
* length, not NUL-terminated. */
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
const uint8_t *target, int64_t target_len,
int32_t want_float);
/* nil <-> None at an (Option T) boundary, and (Some nil)'s refusal — M2 item
* 4. [flan_dyn_is_nil] is the tag test the boundary's runtime half needs and
* does not want to build out of [flan_dyn_tag] and a comparison at every call

View File

@ -296,6 +296,51 @@ int32_t flan_dyn_need_bool(flan_dyn v) {
return c->u.b;
}
/* The cast boundary's tag question — see flan_dyn.h. The stub keeps its own
* trap vocabulary, as every function above it does; what it must agree with
* the real runtime about is the *answer*, 1 for a float box and 0 for an int
* one, because that is what the compiler branches on. The once-per-site
* table is the real runtime's word for word: a program built against the
* stub that warns twice for one line would be a difference in the
* diagnostic, which is the thing this pair exists to keep identical. */
#define STUB_SITE_MAX 64
static struct { const uint8_t *ptr; int64_t len; } stub_warned[STUB_SITE_MAX];
static int stub_warned_count;
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
const uint8_t *target, int64_t target_len,
int32_t want_float) {
cell *c = as(v);
if (c->tag != T_I64 && c->tag != T_F64)
dyn_trap("DynExpectedNumber",
"a numeric cast was written on this value and it is not a number");
int32_t is_float = c->tag == T_F64 ? 1 : 0;
if (is_float != (want_float ? 1 : 0)) {
int first = 1;
for (int i = 0; i < stub_warned_count; i++)
if (stub_warned[i].len == loc_len &&
memcmp(stub_warned[i].ptr, loc, (size_t)loc_len) == 0)
first = 0;
if (first) {
if (stub_warned_count < STUB_SITE_MAX) {
stub_warned[stub_warned_count].ptr = loc;
stub_warned[stub_warned_count].len = loc_len;
stub_warned_count++;
}
fflush(stdout);
fprintf(stderr,
"flan %.*s: (%.*s x) found a dyn holding %s, and converted it "
"to %.*s — warned once for this site\n",
(int)loc_len, (const char *)loc, (int)target_len,
(const char *)target, is_float ? "a float" : "an int",
(int)target_len, (const char *)target);
}
}
return is_float;
}
/* ── Roots ─────────────────────────────────────────────────────────────
*
* Recorded and otherwise ignored. The shadow stack is kept, and its depth

View File

@ -0,0 +1,72 @@
;;;; A numeric cast opens a dyn box — FIX.org 2026-09-20.
;;;;
;;;; Before this, a typed parameter was the only thing in the language that
;;;; could open a box, 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", so it is the spelling
;;;; that should have worked; here it does.
;;;;
;;;; Three behaviours, in the order main runs them:
;;;;
;;;; 1. Same kind. The box holds what the cast asks for, so the cast is the
;;;; unbox and nothing else is written or warned.
;;;; 2. Cross kind. The box holds the other number. The cast still converts —
;;;; exactly as the same cast converts a typed operand of that type — and
;;;; one line goes to stderr naming the site. The two cross-kind casts here
;;;; sit inside a dotimes that runs them eight times each, and stderr gets
;;;; two lines, because the warning is per *site*: these casts live in frame
;;;; loops and a per-value line would be a flood rather than a diagnostic.
;;;; 3. A box holding something that is not a number. That still traps, the way
;;;; a typed parameter traps on it today, and the runtime owns the sentence.
;;;; It is the last thing main does, so everything above it has printed.
;; The box. Unannotated parameters are dyn, so this is the boxing site and the
;; call below is where a typed literal crosses in.
(defn as-dyn [x] dyn x)
(defn main [] ()
(let [i (as-dyn 7)
f (as-dyn 2.5)]
;; ── 1. Same kind: the cast is the unbox ──────────────────────────
;; An int box to every integer width, and a float box to both floats.
;; None of these warns: the box holds what was asked for.
(print (i64 i))
(print "\n")
(print (i64 (i32 i)))
(print "\n")
(print (i64 (u32 i)))
(print "\n")
(print (i64 (u8 i)))
(print "\n")
(print (f64 f))
(print "\n")
(print (f64 (f32 f)))
(print "\n")
;; ── 2. Cross kind: it converts, and warns once for the site ──────
;; (f64 int-box) is 7 -> 7.0, the same widening (f64 7) does on a typed
;; operand. (i64 float-box) is 2.5 -> 2, the same truncation toward zero
;; (i64 2.5) does — range-checked by the emitter's own check_cast, because
;; the arm really is that cast with an unbox in front of it.
;;
;; Eight turns of the loop, two sites, two lines on stderr.
(dotimes [n 8]
(print (f64 i))
(print "\n")
(print (i64 f))
(print "\n"))
;; A third site, and the third warning. An int box straight to f32 is one
;; sitofp from i64, which is what (f32 x) on a typed i64 is. Written down
;; because the tempting "fix" — go through f64 and then narrow — rounds
;; twice and is a different number for values an i64 can hold and an f64
;; cannot round to an f32 the same way.
(print (f64 (f32 i)))
(print "\n"))
;; ── 3. And the box that holds no number ──────────────────────────────
;; A bool, which is what the typed parameter boundary already refuses: a dyn
;; holding true does not satisfy an i64 there and does not satisfy (i64 x)
;; here. The runtime owns the message.
(print (i64 (as-dyn true)))
(print "\n"))

View File

@ -3730,6 +3730,83 @@ level "1"
some_nil ~opt:"-O0" ();
some_nil ~x86:true ();
(* A numeric cast opening a dyn box — FIX.org 2026-09-20.
programs/dyn-cast.flan is one program because the three behaviours are
one story told in order: the same-kind casts print, the cross-kind ones
print and warn, and the non-numeric box ends the process. The trap is
last for the reason [dyn_boundary]'s is one program, one ending.
Three things are asserted, and the third is the one that needed the
work. First, the numbers: the same-kind casts are pure unboxes, and the
cross-kind ones convert the way the same cast converts a typed operand,
so (f64 int-box) is 7 and (i64 float-box) is 2 the truncation toward
zero (i64 2.5) already does, not a rounding this feature invented.
Second, the trap. A bool box is refused by [flan_dyn_cast_kind], which
is the decision to mirror the typed parameter boundary rather than to
invent a rule: a dyn holding true does not satisfy an i64 parameter
today either.
Third, ONCE PER SITE. The two cross-kind casts in the middle sit inside
a [dotimes] that runs eight times, so a per-value warning would put
sixteen lines on stderr; what is counted here is three one for each
of the program's three cross-kind cast *sites*, the third being the
(f32 int-box) after the loop. That count is the whole reason the
runtime carries a table of sites at all: these casts live in frame
loops, sand.flan's at 120fps, and a flood is not a diagnostic.
[run] merges stderr into stdout, so the warnings and the numbers come
back in one string and the count is a count over it. *)
let dyn_cast_out = "7\n7\n7\n7\n2.5\n2.5\n" in
let dyn_cast ?opt ?x86 () =
let exe = compile ?opt ?x86 "programs/dyn-cast.flan" in
let code, text = run exe None in
let name =
"dyn: a numeric cast opens the box, and warns once per site"
^ (match opt with Some o -> ", " ^ o | None -> "")
^ (match x86 with Some true -> ", --x86" | _ -> "")
in
(* Occurrences of the warning's stable half. Counted rather than
matched, because what is being pinned is a number and not a
sentence. *)
let needle = "warned once for this site" in
let warnings =
let n = String.length needle and m = String.length text in
let c = ref 0 in
for i = 0 to m - n do
if String.sub text i n = needle then incr c
done;
!c
in
if code <> 134
|| not (contains text dyn_cast_out)
(* The two cross-kind conversions, by their values: 7 -> 7.0 and
2.5 -> 2, eight times each. *)
|| not (contains text "7\n2\n7\n2\n")
|| warnings <> 3
|| not (contains text "found a dyn holding an int, and converted it to f64")
|| not (contains text "found a dyn holding a float, and converted it to i64")
|| not (contains text "found a dyn holding an int, and converted it to f32")
(* The non-numeric box, in the runtime's own words. *)
|| not (contains text "bool, and a number was wanted")
then begin
incr failures;
Printf.printf
"FAIL %s\n got: %S (exit %d, %d warnings)\n wanted: %S, \
three warnings, then a trap (exit 134)\n"
name text code warnings dyn_cast_out
end;
(try Sys.remove exe with Sys_error _ -> ())
in
dyn_cast ();
dyn_cast ~opt:"-O0" ();
(* The dev daemon's default backend. The parity rule (FIX.org, "x86 tracks
LLVM -O0") is what this row is: the same numbers, the same three
warnings and the same trap, which is only true because the conversion
on each arm is the [Cast] node both backends already lowered rather
than arithmetic this feature wrote twice. *)
dyn_cast ~x86:true ();
(* ── Typed containers into dyn as views, M2 item 3 ────────────────
programs/dyn-view.flan takes its mode from argv, the way bounds.flan
does, because a survey and a trap cannot share a process: mode 0 is

View File

@ -1429,6 +1429,36 @@ let () =
(defn main [] i32\n\
\ (match (unbox-opt (box-it 42)) (Some x) (if (= x 42) 0 1) None 1))";
(* A numeric cast opens a dyn box — FIX.org 2026-09-20. The checker's half
is small: every cast name the language has admits a dyn operand now, and
that is what these rows are. What the cast then *does* is a run-time
question and lives in test_acceptance.ml's dyn-cast rows the same split
as the typed boundary, whose refusal is here and whose trap is there. *)
accepts "every numeric cast takes a dyn"
"(defn d [] dyn 7)\n\
(defn main [] i32\n\
\ (do (i64 (d)) (i32 (d)) (i16 (d)) (i8 (d))\n\
\ (u64 (d)) (u32 (d)) (u16 (d)) (u8 (d))\n\
\ (f64 (d)) (f32 (d))\n\
\ 0))";
(* And a cast the checker could already see was wrong is wrong for the same
reason it was: dyn is admitted by name, not by the numeric test going
soft. A string is not a number and never reaches a runtime tag. *)
rejects_check "a cast still refuses a non-numeric typed operand"
"(defn main [] i32 (i64 \"hi\"))"
~needle:"converts a number";
(* The *generic* cast — [($t x)] in a body whose signature says
[{:where (numeric? $t)}] is a separate arm in the checker and did not
grow a dyn case. It 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 to begin with. The refusal is the bound's, at the call
that would have instantiated it. *)
rejects_check "a generic cast's operand is still what its bound admits"
"(defn conv [x $t] $t {:where (numeric? $t)} (t x))\n\
(defn d [] dyn 7)\n\
(defn main [] i32 (i32 (conv (d))))"
~needle:"numeric?";
(* The map operations ride the words the typed map already owns: get, put,
len, has-key? one question, one word, on both sides. has-key? on a
typed map still checks against its K. *)
@ -1893,8 +1923,16 @@ let () =
keyword-specific one. *)
rejects_check "a keyword needs an enum"
"(defn g [x i32] ()) (defn f [] () (g :space))" ~needle:"is expected here";
rejects_check "a keyword with no expectation converts as dyn"
"(defn f [] () (print (i64 :space)))" ~needle:"found dyn";
(* This row used to be a rejection, and the sentence it wanted was the cast
arm's "converts a number, found dyn". FIX.org 2026-09-20 took that
refusal away on purpose: a numeric cast opens a dyn box, so [(i64 d)]
compiles for every dyn [d] and the question of what the box holds moved
to run time. A keyword's box holds no number and traps there
[flan_dyn_cast_kind]'s sentence, the same one a bool's box gets, pinned
in test_acceptance.ml's dyn-cast rows. What is left here is that the
program is now well-typed, which is the change. *)
accepts "a keyword is dyn, and a numeric cast on it is a run-time question"
"(defn f [] () (print (i64 :space)))";
rejects_check "a keyword that is not a member"
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [] () (g :spcae))"
~needle:"has no member :spcae";