From e7effe5f57562900c5a00ab31e8b1423789ef426 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Sat, 27 Jun 2026 12:34:58 +0900 Subject: [PATCH] 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). --- cmd/wcc/check.c | 101 +++++++++++++++----- selfhost/cmd/wcc/check.ww | 101 ++++++++++++++++---- test/lang/f32_untyped_narrow_test.ww | 138 +++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 41 deletions(-) create mode 100644 test/lang/f32_untyped_narrow_test.ww diff --git a/cmd/wcc/check.c b/cmd/wcc/check.c index ed7099d0..5b7f7a74 100644 --- a/cmd/wcc/check.c +++ b/cmd/wcc/check.c @@ -1087,30 +1087,30 @@ unify_arith(Checker *c, Pos p, Type *a, Type *b) type_name(c->a, a), type_name(c->a, b)); } -/* #104 fold-2: an un-suffixed float literal stays ty_untyped_float through - * the checker, so fold-1's cgen narrow (gated on the node's f32-ness) never - * fires for `let x: f32 = 1.0` — the literal materialises as a double whose - * low 4 bytes (0.0f for clean values) are what the f32 consumer reads. Stamp - * such a 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 - * bit pattern is target-dependent (unlike a width-agnostic int immediate), - * so the value-producing node must carry the f32 type. +/* #104 fold-2 / #120: an un-suffixed float literal stays ty_untyped_float + * through the checker, so fold-1's cgen narrow (gated on the node's f32-ness, + * cgen.c:4163) never fires — the literal materialises as a double whose low 4 + * bytes (0.0f for clean values) are what the f32 consumer reads. Stamp such a + * literal f32 when an f32 target type is in context, mirroring harec's + * lower_implicit_cast (ref/harec/src/check.c:148): a flexible fconst adapts to + * the hinted type exactly as a flexible iconst does. A float literal's bit + * 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 * 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 — - * because the wwstage cgen (selfhost/cmd/wcc/cgenutil.ww exprfloatkind) does - * NOT read node.type_ for a float literal: it hardcodes N_FLOATLIT -> f64 and - * cgbin picks f32 off the OPERANDS' float-kind, not the node stamp. So the - * wwstage materialiser (fold-1 isf32type, the one path that does read the - * stamp) narrows a let-init / return literal correctly, but a stamped literal - * inside an arith-binop or behind a unary minus is NOT narrowed by cgbin / - * the negate — cstage would emit ADDSS/SUBSS while wwstage emits ADDSD/SUBSD, - * breaking the cs==ww byte-id gate. Likewise the wwstage checker has no - * N_ASSIGN check, no param-typed call-arg loop, and a head-only struct - * literal. binop / unary-minus / assign / call-arg / struct-field therefore - * wait on the wwstage cgen + checker gaining those (#120). */ + * #120 broadens the reach (was let-init / return only): descend the + * lower_implicit_cast operand shapes so every untyped float LEAF in an f32 + * context gets the stamp — a unary ± / paren-cast wrapper, both operands of an + * arith binop (harec lowers a binop's operands to its result type, + * ref/harec/src/check.c:1347-1348; this is the only path that reaches a + * literal-on-BOTH-sides `2.0 + 3.0` under an f32 target — narrowing per-leaf, + * never via an f64 intermediate that would double-round), and each element of + * an array literal against the array's element type. f64-only targets recurse + * harmlessly (the leaf gate stays TY_F32). The (B) sibling-lowering of a + * comparison's untyped operand — which has no f32 target above (its result is + * bool) — lives in cbinop. */ static void 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 * a doubly-aliased f32 target stamps in both stages or neither. */ Type *u = type_chase_named(target); - if (u == NULL || u->kind != TY_F32) + if (u == NULL) return; - if (n->kind == N_FLOATLIT && n->type == ty_untyped_float) - n->type = ty_f32; + 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; + 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 @@ -1189,6 +1227,16 @@ cbinop(Checker *c, Node *n) { Type *l = cexpr(c, n->lhs); 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) { case TK_PLUS: case TK_MINUS: case TK_STAR: case TK_SLASH: case TK_PERCENT: @@ -1892,6 +1940,8 @@ cexpr(Checker *c, Node *n) && !assignable_addrfn(c, p->type, a)) err(c, a->pos, "argument type %s not assignable to %s", 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. * #31/#33: a bare array LITERAL arg has no backing — * loud-reject (supported only at a `let`). */ @@ -1924,6 +1974,8 @@ cexpr(Checker *c, Node *n) !assignable_addrfn(c, l, n->rhs)) err(c, n->pos, "cannot assign %s to %s", 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. * #31/#33: a bare array LITERAL rhs has no backing — * 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", f->str, type_name(c->a, vt), 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; diff --git a/selfhost/cmd/wcc/check.ww b/selfhost/cmd/wcc/check.ww index 916e97d0..1c4ae9d0 100644 --- a/selfhost/cmd/wcc/check.ww +++ b/selfhost/cmd/wcc/check.ww @@ -2696,27 +2696,70 @@ 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 -// for the full rationale + the rule-10 scope note). Stamp an un-suffixed -// float literal (whose type_ is the untyped_float singleton) as f32 when the -// target type resolves to f32, so fold-1's cgen narrow (isf32type, cgenexpr. -// ww) fires off the now-f32 node.type_. SCOPED to a DIRECT untyped_float -// N_FLOATLIT at let-init / return only: the wwstage cgen's exprfloatkind -// (cgenutil.ww) hardcodes N_FLOATLIT -> f64 and cgbin / the unary negate pick -// f32 off the operands' float-kind, not the node stamp, so a stamped literal -// inside an arith-binop / behind a unary minus does NOT narrow there — -// binop / unary-minus / assign / call-arg / struct-field wait on #120. +// for the full rationale). Stamp an un-suffixed float literal (whose type_ is +// the untyped_float singleton) as f32 when the target type resolves to f32, so +// fold-1's cgen narrow (isf32type, cgenexpr.ww) fires off the now-f32 +// node.type_. SCOPED to untyped_float -> f32. #120 broadens the reach (was +// let-init / return only): descend the harec lower_implicit_cast operand +// shapes so every untyped float LEAF in an f32 context gets the stamp — a +// unary ± / paren-cast wrapper, both arith-binop operands (the only path that +// reaches a literal-on-both-sides under an f32 target, per-leaf so no f64 +// 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 = { if (e == nil) { return; }; if (target == nil) { return; }; let tu: *syntax.node = resolvealias(c, unwrapbang(target)); if (tu == nil) { return; }; - if (tu.kind != syntax.nkind.N_TNAME) { return; }; - if (!syntax.streq(tu.str, "f32")) { return; }; - if (e.kind == syntax.nkind.N_FLOATLIT) { - if ((e.type_: *syntax.tinfo) == c.tc.tyuntypedfloat) { - let f32t: *syntax.node = mktname(c, "f32"); - e.type_ = tinfofornode(c, f32t): *void; + let k: syntax.nkind = e.kind; + if (k == syntax.nkind.N_UN) { + 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) { + let f32t: *syntax.node = mktname(c, "f32"); + e.type_ = tinfofornode(c, f32t): *void; + }; + }; + return; }; }; @@ -2771,6 +2814,16 @@ fn binoptype(c: *checker, e: *syntax.node) *syntax.node = { let op: syntax.tkind = e.op; let ltn: *syntax.node = exprtype(c, e.lhs, 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, // incl untyped_int / untyped_rune / alias-chased) — NOT isinttypeast, // 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; for (fi != nil) { if (fi.kind == syntax.nkind.N_FIELD - && fi.lhs != nil - && fi.lhs.kind == syntax.nkind.N_ARRLIT) { + && fi.lhs != nil) { let ftn: *syntax.node = nil; let tf: *syntax.node = stn.list; for (tf != nil) { @@ -3960,7 +4012,13 @@ fn exprtype(c: *checker, e: *syntax.node, hint: *syntax.node) *syntax.node = { tf = tf.next; }; if (ftn != nil) { - checkarrlitfits(c, ftn, fi.lhs); + // #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); + }; }; }; fi = fi.next; @@ -5666,6 +5724,10 @@ fn desugarcallargs(c: *checker, n: *syntax.node) void = { }; if (param.op != syntax.tkind.TK_ELLIPSIS) { 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 // (`take('b')` where take(b: u8)). Override atype to the // 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 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 // target (`buf[i] = 'F'`). Override rtn to the integer target so a // general assign typecheck accepts, mirroring cstage's coarse diff --git a/test/lang/f32_untyped_narrow_test.ww b/test/lang/f32_untyped_narrow_test.ww new file mode 100644 index 00000000..33c36878 --- /dev/null +++ b/test/lang/f32_untyped_narrow_test.ww @@ -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); +};