A numeric cast opens a dyn box itself

This commit is contained in:
Joseph Ferano 2026-09-20 13:52:27 +07:00
parent 2127d4d070
commit b6ea14f930
5 changed files with 222 additions and 1 deletions

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, 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) {

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 ? "float" : "int",
(int)target_len, (const char *)target);
}
}
return is_float;
}
/* ── Roots ─────────────────────────────────────────────────────────────
*
* Recorded and otherwise ignored. The shadow stack is kept, and its depth