From b6ea14f93027acca5ab5a18a1b31c7df1f121d2c Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:52:27 +0700 Subject: [PATCH 1/4] A numeric cast opens a dyn box itself --- lib/check.ml | 72 +++++++++++++++++++++++++++++++++- lib/emit.ml | 5 +++ runtime/flan_dyn.c | 86 +++++++++++++++++++++++++++++++++++++++++ runtime/flan_dyn.h | 15 +++++++ runtime/flan_dyn_stub.c | 45 +++++++++++++++++++++ 5 files changed, 222 insertions(+), 1 deletion(-) diff --git a/lib/check.ml b/lib/check.ml index e350325..87f2457 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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 diff --git a/lib/emit.ml b/lib/emit.ml index 3eeaecb..f6261c7 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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. diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index 9cfd605..8e13dd4 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -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, tag_of(v), (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) { diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 6a82133..2f95b6b 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -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 diff --git a/runtime/flan_dyn_stub.c b/runtime/flan_dyn_stub.c index 779f49f..b11cff6 100644 --- a/runtime/flan_dyn_stub.c +++ b/runtime/flan_dyn_stub.c @@ -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 ? "float" : "int", + (int)target_len, (const char *)target); + } + } + return is_float; +} + /* ── Roots ───────────────────────────────────────────────────────────── * * Recorded and otherwise ignored. The shadow stack is kept, and its depth From 10610fb76b57203ecaee8dbb96019db2b28547e9 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:54:07 +0700 Subject: [PATCH 2/4] The corpus program for a cast that opens a box --- runtime/flan_dyn.c | 4 +-- runtime/flan_dyn_stub.c | 2 +- test/programs/dyn-cast.flan | 72 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 test/programs/dyn-cast.flan diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index 8e13dd4..3a4436f 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -1219,8 +1219,8 @@ int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len, "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, tag_of(v), (int)target_len, - (const char *)target); + (const char *)target, is_float ? "a float" : "an int", + (int)target_len, (const char *)target); } return is_float; } diff --git a/runtime/flan_dyn_stub.c b/runtime/flan_dyn_stub.c index b11cff6..ce64ee7 100644 --- a/runtime/flan_dyn_stub.c +++ b/runtime/flan_dyn_stub.c @@ -334,7 +334,7 @@ int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len, "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 ? "float" : "int", + (const char *)target, is_float ? "a float" : "an int", (int)target_len, (const char *)target); } } diff --git a/test/programs/dyn-cast.flan b/test/programs/dyn-cast.flan new file mode 100644 index 0000000..ac52711 --- /dev/null +++ b/test/programs/dyn-cast.flan @@ -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")) From a45e3806af8b60a563c4b79c1d57911272d0e537 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:57:43 +0700 Subject: [PATCH 3/4] The cast that opens a box, pinned on both backends --- test/test_acceptance.ml | 77 +++++++++++++++++++++++++++++++++++++++++ test/test_flan.ml | 42 ++++++++++++++++++++-- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index fdd20bf..b9fd9e6 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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 diff --git a/test/test_flan.ml b/test/test_flan.ml index d496671..8c474ae 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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"; From 34790e78af74fd9a3674aefb478328dc2d866ccf Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 13:58:15 +0700 Subject: [PATCH 4/4] The cast that opens a box, written down --- FIX.org | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) 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.