check: narrow untyped float literals to f32 in f32 context (#120)

An untyped float literal defaults to f64, so in an f32 context it was
materialized as f64 then bit-truncated by a raw MOVSS (low-32 reinterpret)
rather than narrowed -- e.g. `let x: f32 = 2.0f32; x * 3.0` multiplied by
0.0f. Twelve byte-id-gate-blind both-wrong miscompiles, all this one cause
(compare, binop, call-arg, struct-field, array-elem against an untyped
literal).

Broaden coerce_floatlit to stamp the untyped fconst type_=f32 across the
f32-context sites (assign rhs, call-arg, struct-field, array-elem) and to
descend the implicit-cast shapes (peel unary +/-/cast, recurse binop
operands AND the binop node, recurse arrlit elems), mirroring harec's
lower_implicit_cast. The existing CVTSD2SS gate then fires; cgen is
unchanged. f64 contexts are untouched -- the stamp is gated on TY_F32.

Surfaced by the float codegen sub-hunt (= the deferred #120). Pinned by
test/lang/f32_untyped_narrow_test.ww (22 value-asserting rows incl. f64
controls; reddens on revert).
This commit is contained in:
2026-06-27 12:34:58 +09:00
parent 98b84986b7
commit e7effe5f57
3 changed files with 299 additions and 41 deletions

View File

@@ -1087,30 +1087,30 @@ unify_arith(Checker *c, Pos p, Type *a, Type *b)
type_name(c->a, a), type_name(c->a, b)); type_name(c->a, a), type_name(c->a, b));
} }
/* #104 fold-2: an un-suffixed float literal stays ty_untyped_float through /* #104 fold-2 / #120: an un-suffixed float literal stays ty_untyped_float
* the checker, so fold-1's cgen narrow (gated on the node's f32-ness) never * through the checker, so fold-1's cgen narrow (gated on the node's f32-ness,
* fires for `let x: f32 = 1.0` — the literal materialises as a double whose * cgen.c:4163) never fires — the literal materialises as a double whose low 4
* low 4 bytes (0.0f for clean values) are what the f32 consumer reads. Stamp * bytes (0.0f for clean values) are what the f32 consumer reads. Stamp such a
* such a literal f32 when an f32 target type is in context, mirroring harec's * literal f32 when an f32 target type is in context, mirroring harec's
* lower_implicit_cast sites (ref/harec/src/check.c:148). A float literal's * lower_implicit_cast (ref/harec/src/check.c:148): a flexible fconst adapts to
* bit pattern is target-dependent (unlike a width-agnostic int immediate), * the hinted type exactly as a flexible iconst does. A float literal's bit
* so the value-producing node must carry the f32 type. * pattern is target-dependent (unlike a width-agnostic int immediate), so the
* value-producing node must carry the f32 type.
* *
* SCOPED to untyped_float -> f32 ONLY: untyped_float -> f64 already works via * SCOPED to untyped_float -> f32 ONLY: untyped_float -> f64 already works via
* cgen's double default, so stamping it would broaden the surface for no gain. * cgen's double default, so stamping it would broaden the surface for no gain.
* *
* Symmetric subset (rule 10), DIRECT N_FLOATLIT at let-init / return only * #120 broadens the reach (was let-init / return only): descend the
* because the wwstage cgen (selfhost/cmd/wcc/cgenutil.ww exprfloatkind) does * lower_implicit_cast operand shapes so every untyped float LEAF in an f32
* NOT read node.type_ for a float literal: it hardcodes N_FLOATLIT -> f64 and * context gets the stamp — a unary ± / paren-cast wrapper, both operands of an
* cgbin picks f32 off the OPERANDS' float-kind, not the node stamp. So the * arith binop (harec lowers a binop's operands to its result type,
* wwstage materialiser (fold-1 isf32type, the one path that does read the * ref/harec/src/check.c:1347-1348; this is the only path that reaches a
* stamp) narrows a let-init / return literal correctly, but a stamped literal * literal-on-BOTH-sides `2.0 + 3.0` under an f32 target — narrowing per-leaf,
* inside an arith-binop or behind a unary minus is NOT narrowed by cgbin / * never via an f64 intermediate that would double-round), and each element of
* the negate — cstage would emit ADDSS/SUBSS while wwstage emits ADDSD/SUBSD, * an array literal against the array's element type. f64-only targets recurse
* breaking the cs==ww byte-id gate. Likewise the wwstage checker has no * harmlessly (the leaf gate stays TY_F32). The (B) sibling-lowering of a
* N_ASSIGN check, no param-typed call-arg loop, and a head-only struct * comparison's untyped operand — which has no f32 target above (its result is
* literal. binop / unary-minus / assign / call-arg / struct-field therefore * bool) — lives in cbinop. */
* wait on the wwstage cgen + checker gaining those (#120). */
static void static void
coerce_floatlit(Node *n, Type *target) coerce_floatlit(Node *n, Type *target)
{ {
@@ -1119,10 +1119,48 @@ coerce_floatlit(Node *n, Type *target)
/* Chase the full alias chain (wwstage resolvealias does the same), so /* Chase the full alias chain (wwstage resolvealias does the same), so
* a doubly-aliased f32 target stamps in both stages or neither. */ * a doubly-aliased f32 target stamps in both stages or neither. */
Type *u = type_chase_named(target); Type *u = type_chase_named(target);
if (u == NULL || u->kind != TY_F32) if (u == NULL)
return; return;
if (n->kind == N_FLOATLIT && n->type == ty_untyped_float) switch (n->kind) {
case N_UN:
if (n->op == TK_MINUS || n->op == TK_PLUS)
coerce_floatlit(n->lhs, target);
return;
case N_CAST:
coerce_floatlit(n->lhs, target);
return;
case N_BIN:
switch (n->op) {
case TK_PLUS: case TK_MINUS: case TK_STAR: case TK_SLASH:
coerce_floatlit(n->lhs, target);
coerce_floatlit(n->rhs, target);
/* harec lowers the binop's RESULT to the hint too, not
* only its operands. A literal-on-both-sides binop's node
* stays ty_untyped_float (unify_arith of two untyped), and
* cstage's arith cgen keys the op/spill width on the BINOP
* node (cgen.c:4944 node_isf32(n)) — so without stamping the
* node f32 it emits ADDSD over the f32-narrowed operands
* (garbage), diverging from wwstage (which keys on operands).
* Stamping the node converges both stages on ADDSS. */
if (u->kind == TY_F32 && n->type == ty_untyped_float)
n->type = ty_f32; n->type = ty_f32;
break;
default:
break;
}
return;
case N_ARRLIT:
if (u->kind == TY_ARRAY)
for (Node *e = n->list; e; e = e->next)
coerce_floatlit(e, u->sub);
return;
case N_FLOATLIT:
if (u->kind == TY_F32 && n->type == ty_untyped_float)
n->type = ty_f32;
return;
default:
return;
}
} }
/* desugar_arrayslice — #258. The single shared injection point for the /* desugar_arrayslice — #258. The single shared injection point for the
@@ -1189,6 +1227,16 @@ cbinop(Checker *c, Node *n)
{ {
Type *l = cexpr(c, n->lhs); Type *l = cexpr(c, n->lhs);
Type *r = cexpr(c, n->rhs); Type *r = cexpr(c, n->rhs);
/* #120 (B): a binop/compare with one f32 operand lowers an untyped-
* float peer to f32 — harec unifies both operands to the operand type
* (ref/harec/src/check.c:1347-1348). A comparison's result is bool, so
* no f32 target is above its operands and this sibling is their only
* lowering path. Each coerce is inert unless the PEER type resolves f32
* and this operand carries an untyped float leaf, so a both-untyped pair
* (`4.0 == 5.0`, no f32 context) stays f64. Held byte-id-symmetric with
* wwstage binoptype (two unconditional, internally-gated coerce calls). */
coerce_floatlit(n->rhs, l);
coerce_floatlit(n->lhs, r);
switch (n->op) { switch (n->op) {
case TK_PLUS: case TK_MINUS: case TK_STAR: case TK_SLASH: case TK_PLUS: case TK_MINUS: case TK_STAR: case TK_SLASH:
case TK_PERCENT: case TK_PERCENT:
@@ -1892,6 +1940,8 @@ cexpr(Checker *c, Node *n)
&& !assignable_addrfn(c, p->type, a)) && !assignable_addrfn(c, p->type, a))
err(c, a->pos, "argument type %s not assignable to %s", err(c, a->pos, "argument type %s not assignable to %s",
type_name(c->a, at), type_name(c->a, p->type)); type_name(c->a, at), type_name(c->a, p->type));
/* #120: `f(1.0)` narrows the arg literal to the param's f32. */
coerce_floatlit(a, p->type);
/* #258: `f(arr)` borrows the array as a full slice. /* #258: `f(arr)` borrows the array as a full slice.
* #31/#33: a bare array LITERAL arg has no backing — * #31/#33: a bare array LITERAL arg has no backing —
* loud-reject (supported only at a `let`). */ * loud-reject (supported only at a `let`). */
@@ -1924,6 +1974,8 @@ cexpr(Checker *c, Node *n)
!assignable_addrfn(c, l, n->rhs)) !assignable_addrfn(c, l, n->rhs))
err(c, n->pos, "cannot assign %s to %s", err(c, n->pos, "cannot assign %s to %s",
type_name(c->a, r), type_name(c->a, l)); type_name(c->a, r), type_name(c->a, l));
/* #120: `w = 1.0` narrows the rhs literal to the lvalue's f32. */
coerce_floatlit(n->rhs, l);
/* #258: `s = arr` borrows the array as a full slice. /* #258: `s = arr` borrows the array as a full slice.
* #31/#33: a bare array LITERAL rhs has no backing — * #31/#33: a bare array LITERAL rhs has no backing —
* loud-reject (supported only at a `let`). */ * loud-reject (supported only at a `let`). */
@@ -1966,6 +2018,9 @@ cexpr(Checker *c, Node *n)
err(c, f->pos, "field %s: %s not assignable to %s", err(c, f->pos, "field %s: %s not assignable to %s",
f->str, type_name(c->a, vt), f->str, type_name(c->a, vt),
type_name(c->a, match->type)); type_name(c->a, match->type));
/* #120: `S{ f: 1.0 }` narrows the init to the field's f32. */
if (match != NULL)
coerce_floatlit(f->lhs, match->type);
} }
} }
return n->type = t; return n->type = t;

View File

@@ -2696,28 +2696,71 @@ fn unifyarith(c: *checker, e: *syntax.node, ltn: *syntax.node, rtn: *syntax.node
}; };
// coercefloatlit — twin of cstage cmd/wcc/check.c coerce_floatlit (see there // coercefloatlit — twin of cstage cmd/wcc/check.c coerce_floatlit (see there
// for the full rationale + the rule-10 scope note). Stamp an un-suffixed // for the full rationale). Stamp an un-suffixed float literal (whose type_ is
// float literal (whose type_ is the untyped_float singleton) as f32 when the // the untyped_float singleton) as f32 when the target type resolves to f32, so
// target type resolves to f32, so fold-1's cgen narrow (isf32type, cgenexpr. // fold-1's cgen narrow (isf32type, cgenexpr.ww) fires off the now-f32
// ww) fires off the now-f32 node.type_. SCOPED to a DIRECT untyped_float // node.type_. SCOPED to untyped_float -> f32. #120 broadens the reach (was
// N_FLOATLIT at let-init / return only: the wwstage cgen's exprfloatkind // let-init / return only): descend the harec lower_implicit_cast operand
// (cgenutil.ww) hardcodes N_FLOATLIT -> f64 and cgbin / the unary negate pick // shapes so every untyped float LEAF in an f32 context gets the stamp — a
// f32 off the operands' float-kind, not the node stamp, so a stamped literal // unary ± / paren-cast wrapper, both arith-binop operands (the only path that
// inside an arith-binop / behind a unary minus does NOT narrow there — // reaches a literal-on-both-sides under an f32 target, per-leaf so no f64
// binop / unary-minus / assign / call-arg / struct-field wait on #120. // intermediate double-rounds), and each array-literal element against the
// array's element type. The comparison sibling (no f32 target above) is in
// binoptype.
fn coercefloatlit(c: *checker, e: *syntax.node, target: *syntax.node) void = { fn coercefloatlit(c: *checker, e: *syntax.node, target: *syntax.node) void = {
if (e == nil) { return; }; if (e == nil) { return; };
if (target == nil) { return; }; if (target == nil) { return; };
let tu: *syntax.node = resolvealias(c, unwrapbang(target)); let tu: *syntax.node = resolvealias(c, unwrapbang(target));
if (tu == nil) { return; }; if (tu == nil) { return; };
if (tu.kind != syntax.nkind.N_TNAME) { return; }; let k: syntax.nkind = e.kind;
if (!syntax.streq(tu.str, "f32")) { return; }; if (k == syntax.nkind.N_UN) {
if (e.kind == syntax.nkind.N_FLOATLIT) { if (e.op == syntax.tkind.TK_MINUS || e.op == syntax.tkind.TK_PLUS) {
coercefloatlit(c, e.lhs, target);
};
return;
};
if (k == syntax.nkind.N_CAST) {
coercefloatlit(c, e.lhs, target);
return;
};
if (k == syntax.nkind.N_BIN) {
if (e.op == syntax.tkind.TK_PLUS || e.op == syntax.tkind.TK_MINUS ||
e.op == syntax.tkind.TK_STAR || e.op == syntax.tkind.TK_SLASH) {
coercefloatlit(c, e.lhs, target);
coercefloatlit(c, e.rhs, target);
// harec lowers the binop's RESULT to the hint too, not only
// its operands. A literal-on-both-sides binop node stays
// untyped_float; cstage's arith cgen keys op-width on the
// BINOP node (cgen.c:4944), so the node must carry f32 to keep
// both stages on ADDSS. Mirror cstage coerce_floatlit N_BIN.
if (tu.kind == syntax.nkind.N_TNAME && syntax.streq(tu.str, "f32")) {
if ((e.type_: *syntax.tinfo) == c.tc.tyuntypedfloat) {
let f32b: *syntax.node = mktname(c, "f32");
e.type_ = tinfofornode(c, f32b): *void;
};
};
};
return;
};
if (k == syntax.nkind.N_ARRLIT) {
if (tu.kind == syntax.nkind.N_TARRAY) {
let it: *syntax.node = e.list;
for (it != nil) {
coercefloatlit(c, it, tu.lhs);
it = it.next;
};
};
return;
};
if (k == syntax.nkind.N_FLOATLIT) {
if (tu.kind == syntax.nkind.N_TNAME && syntax.streq(tu.str, "f32")) {
if ((e.type_: *syntax.tinfo) == c.tc.tyuntypedfloat) { if ((e.type_: *syntax.tinfo) == c.tc.tyuntypedfloat) {
let f32t: *syntax.node = mktname(c, "f32"); let f32t: *syntax.node = mktname(c, "f32");
e.type_ = tinfofornode(c, f32t): *void; e.type_ = tinfofornode(c, f32t): *void;
}; };
}; };
return;
};
}; };
// coercerunelit — #29: an un-suffixed rune literal narrowing into an // coercerunelit — #29: an un-suffixed rune literal narrowing into an
@@ -2771,6 +2814,16 @@ fn binoptype(c: *checker, e: *syntax.node) *syntax.node = {
let op: syntax.tkind = e.op; let op: syntax.tkind = e.op;
let ltn: *syntax.node = exprtype(c, e.lhs, nil); let ltn: *syntax.node = exprtype(c, e.lhs, nil);
let rtn: *syntax.node = exprtype(c, e.rhs, nil); let rtn: *syntax.node = exprtype(c, e.rhs, nil);
// #120 (B): a binop/compare with one f32 operand lowers an untyped-
// float peer to f32 — harec unifies both operands to the operand type
// (ref/harec/src/check.c:1347-1348). A comparison's result is bool, so
// no f32 target is above its operands and this sibling is their only
// lowering path. coercefloatlit's internal f32-target + untyped-leaf
// gates make each call inert unless the OTHER operand resolves f32 and
// this one carries an untyped float leaf; a both-untyped pair stays f64.
// Mirror cstage cbinop (cmd/wcc/check.c:1190).
coercefloatlit(c, e.rhs, ltn);
coercefloatlit(c, e.lhs, rtn);
// #38/F2: ptr ± int / int + ptr use intkindast (the type_isint mirror, // #38/F2: ptr ± int / int + ptr use intkindast (the type_isint mirror,
// incl untyped_int / untyped_rune / alias-chased) — NOT isinttypeast, // incl untyped_int / untyped_rune / alias-chased) — NOT isinttypeast,
// which misses untyped_int. cstage's ptr-arith arm gates on type_isint // which misses untyped_int. cstage's ptr-arith arm gates on type_isint
@@ -3949,8 +4002,7 @@ fn exprtype(c: *checker, e: *syntax.node, hint: *syntax.node) *syntax.node = {
let fi: *syntax.node = e.list; let fi: *syntax.node = e.list;
for (fi != nil) { for (fi != nil) {
if (fi.kind == syntax.nkind.N_FIELD if (fi.kind == syntax.nkind.N_FIELD
&& fi.lhs != nil && fi.lhs != nil) {
&& fi.lhs.kind == syntax.nkind.N_ARRLIT) {
let ftn: *syntax.node = nil; let ftn: *syntax.node = nil;
let tf: *syntax.node = stn.list; let tf: *syntax.node = stn.list;
for (tf != nil) { for (tf != nil) {
@@ -3960,9 +4012,15 @@ fn exprtype(c: *checker, e: *syntax.node, hint: *syntax.node) *syntax.node = {
tf = tf.next; tf = tf.next;
}; };
if (ftn != nil) { if (ftn != nil) {
// #120: `S{ f: 1.0 }` narrows the
// field init to the field's f32.
// Mirror cstage N_STRUCTLIT site.
coercefloatlit(c, fi.lhs, ftn);
if (fi.lhs.kind == syntax.nkind.N_ARRLIT) {
checkarrlitfits(c, ftn, fi.lhs); checkarrlitfits(c, ftn, fi.lhs);
}; };
}; };
};
fi = fi.next; fi = fi.next;
}; };
}; };
@@ -5666,6 +5724,10 @@ fn desugarcallargs(c: *checker, n: *syntax.node) void = {
}; };
if (param.op != syntax.tkind.TK_ELLIPSIS) { if (param.op != syntax.tkind.TK_ELLIPSIS) {
let atype: *syntax.node = exprtype(c, a, nil); let atype: *syntax.node = exprtype(c, a, nil);
// #120: `f(1.0)` narrows the arg literal to the
// param's f32. Mirror cstage cmd/wcc/check.c N_CALL
// coerce_floatlit at the param-typed arg.
coercefloatlit(c, a, param.lhs);
// #29: a rune literal narrowing into an integer param // #29: a rune literal narrowing into an integer param
// (`take('b')` where take(b: u8)). Override atype to the // (`take('b')` where take(b: u8)). Override atype to the
// integer target so c3's general isassignable accepts, // integer target so c3's general isassignable accepts,
@@ -5745,6 +5807,9 @@ fn checkassign(c: *checker, n: *syntax.node) void = {
}; };
let ltn: *syntax.node = exprtype(c, n.lhs, nil); let ltn: *syntax.node = exprtype(c, n.lhs, nil);
let rtn: *syntax.node = exprtype(c, n.rhs, nil); let rtn: *syntax.node = exprtype(c, n.rhs, nil);
// #120: `w = 1.0` narrows the rhs literal to the lvalue's f32. Mirror
// cstage cmd/wcc/check.c N_ASSIGN coerce_floatlit.
coercefloatlit(c, n.rhs, ltn);
// #29: a rune literal narrowing into an integer assign/index-store // #29: a rune literal narrowing into an integer assign/index-store
// target (`buf[i] = 'F'`). Override rtn to the integer target so a // target (`buf[i] = 'F'`). Override rtn to the integer target so a
// general assign typecheck accepts, mirroring cstage's coarse // general assign typecheck accepts, mirroring cstage's coarse

View File

@@ -0,0 +1,138 @@
// f32_untyped_narrow_test — #120: an UN-suffixed float literal (defaults to
// untyped_float/f64) used in an f32 context must narrow to single precision,
// exactly as the f32-suffixed literal already does (#104 fold-1). Pre-#120 the
// untyped literal stayed untyped_float through the checker, so cgen's CVTSD2SS
// narrow (gated on the node's f32 stamp) never fired: the literal materialised
// as a 64-bit double in X0 and the downstream f32 consumer MOVSS-read the low 4
// bytes (0.0f for clean values; garbage for others). The fix stamps the literal
// (and, for an arith binop, its operands AND result node) f32 in the checker at
// every harec lower_implicit_cast site — binop operands, comparison sibling,
// unary ±, assign rhs, param-typed call-arg, struct-field init, array element.
//
// Byte-id is BLIND to this class (both stages were wrong-but-identical), so the
// asserts below are VALUE-asserting: each redden on pre-fix HEAD (0.0f / false);
// the cstage `ww test` run is the live net, the T2 byte-id gate rides along.
//
// Test-design: ARITHMETIC rows use exactly-representable values (2/3/4/5/6) so a
// rounded product can't false-redden; pure COERCION round-trips use a rounding
// witness (0.1/0.2/1.3, whose f32 and f64 roundings differ) to also catch a
// low-32 reinterpret and a wrong-rounding regression.
package f32_untyped_narrow_test;
type S = struct { f: f32 };
fn ca(a: f32) f32 = { return a; };
// --- binop ×4 (exact) + operand-order ---
@test fn binop_mul() void = {
let x: f32 = 2.0f32;
assert(x * 3.0 == 6.0f32);
};
@test fn binop_add() void = {
let x: f32 = 2.0f32;
assert(x + 4.0 == 6.0f32);
};
@test fn binop_sub() void = {
let x: f32 = 5.0f32;
assert(x - 1.0 == 4.0f32);
};
@test fn binop_div() void = {
let x: f32 = 8.0f32;
assert(x / 2.0 == 4.0f32);
};
@test fn binop_lit_left() void = {
let x: f32 = 2.0f32;
assert(3.0 * x == 6.0f32);
};
// --- compare ×6 (exact); the !=, >, >= rows assert a FALSE result (their
// pre-fix low-32 garbage flips it true), so they need the negation. ---
@test fn cmp_eq() void = {
let y: f32 = 4.0f32;
assert(y == 4.0);
};
@test fn cmp_ne() void = {
let y: f32 = 4.0f32;
assert(!(y != 4.0));
};
@test fn cmp_lt() void = {
let y: f32 = 4.0f32;
assert(y < 5.0);
};
@test fn cmp_gt() void = {
let y: f32 = 4.0f32;
assert(!(y > 5.0));
};
@test fn cmp_le() void = {
let y: f32 = 4.0f32;
assert(y <= 4.0);
};
@test fn cmp_ge() void = {
let y: f32 = 4.0f32;
assert(!(y >= 5.0));
};
// --- unary neg (exact) ---
@test fn unary_neg() void = {
let z: f32 = -2.0;
assert(z == -2.0f32);
};
// --- assign rhs (witness) ---
@test fn assign_rhs() void = {
let w: f32 = 0.0f32;
w = 0.1;
assert(w == 0.1f32);
};
// --- param-typed call-arg (witness) ---
@test fn call_arg() void = {
assert(ca(0.1) == 0.1f32);
};
// --- struct-field init (witness) ---
@test fn struct_field() void = {
let s: S = S { f = 1.3 };
assert(s.f == 1.3f32);
};
// --- array element (witness, multi-leaf: each untyped leaf coerces alone) ---
@test fn array_elem() void = {
let a: [2]f32 = [0.1, 0.2];
assert(a[0] == 0.1f32);
assert(a[1] == 0.2f32);
};
// --- let-store (witness) ---
@test fn let_store() void = {
let v: f32 = 1.3;
assert(v == 1.3f32);
};
// --- literal-on-BOTH-sides under an f32 target (exact, must-green): the
// recursion narrows per-leaf AND stamps the binop result f32, so it computes
// 5.0f directly (no f64 intermediate). reddens with garbage if either half
// of the narrow fails. ---
@test fn both_sides() void = {
let q: f32 = 2.0 + 3.0;
assert(q == 5.0f32);
};
// --- CONTROL rows: green pre AND post-fix (no regression / no over-stamp). ---
@test fn ctrl_suffixed() void = {
let s: f32 = 0.1f32;
assert(s == 0.1f32);
};
@test fn ctrl_explicit_cast() void = {
assert((4.0: f32) == 4.0f32);
};
@test fn ctrl_no_f32_context() void = {
// no f32 anywhere → both literals default to f64; stays f64, false.
let b: bool = (4.0 == 5.0);
assert(b == false);
};
@test fn ctrl_f64_untouched() void = {
let d: f64 = 1.3;
assert(d == 1.3f64);
};