From b69ec43ba336c77c06c0338742779f8fc3430f70 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 08:23:59 +0300 Subject: [PATCH] fix(ir): infer mixed int+float arithmetic as the promoted float [F0.7] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExprTyper.inferType`'s binary-op arm inferred every non-comparison op from the LHS alone, so `M + 0.5` (s64 + f64) statically typed as s64 while `0.5 + M` typed as f64 — operand-order-dependent. The value path (`lowerBinaryOp`) already promoted int×float → float, so static inference disagreed with the value: `M + 0.5` formatted as a truncated int and a typed const `BAD : s64 : M + 0.5` was accepted+truncated (issue 0088 mixed-numeric escape). Extract the value path's inline promotion into a shared `Lowering.arithResultType(lhs, rhs)` and reuse it at both sites, so arithmetic / bitwise / shift inference reports exactly the type the lowered value carries — int LHS × float RHS → the float, order- independent. The value-path behavior is unchanged (the block is moved verbatim into the helper), so no IR shifts; the suite stays green. The typed-const validation reuses `inferExprType`, so this auto-closes the escape with no change to the validation logic. - examples/1143: BAD/BAD2 (`s64 : M + 0.5`, `s64 : 0.5 + M`) rejected in both operand orders. - examples/0162: MF/MFR (`f64 : M + 0.5`, `f64 : 0.5 + M`) fold to 2.5. - examples/0163 (new): pins the inference fix in a value context (`print("{}", n + 0.5)` formats the float, both orders, +-*/, f32). - expr_typer.test.zig: arithResultType + mixed-arithmetic inference. - specs.md / readme.md: document the numeric-promotion rule. - issues/0088: RESOLVED banner notes the inferExprType root fix. --- ...0162-types-typed-module-const-roundtrip.sx | 25 +++++++--- .../0163-types-mixed-numeric-promotion.sx | 35 ++++++++++++++ ...diagnostics-typed-module-const-mismatch.sx | 20 +++++--- ...-types-typed-module-const-roundtrip.stdout | 1 + .../0163-types-mixed-numeric-promotion.exit | 1 + .../0163-types-mixed-numeric-promotion.stderr | 1 + .../0163-types-mixed-numeric-promotion.stdout | 5 ++ ...nostics-typed-module-const-mismatch.stderr | 48 ++++++++++++------- ...-typed-module-const-annotation-mismatch.md | 34 +++++++++++-- readme.md | 5 +- specs.md | 13 +++++ src/ir/expr_typer.test.zig | 42 +++++++++++++++- src/ir/expr_typer.zig | 7 ++- src/ir/lower.zig | 33 ++++++++----- 14 files changed, 218 insertions(+), 52 deletions(-) create mode 100644 examples/0163-types-mixed-numeric-promotion.sx create mode 100644 examples/expected/0163-types-mixed-numeric-promotion.exit create mode 100644 examples/expected/0163-types-mixed-numeric-promotion.stderr create mode 100644 examples/expected/0163-types-mixed-numeric-promotion.stdout diff --git a/examples/0162-types-typed-module-const-roundtrip.sx b/examples/0162-types-typed-module-const-roundtrip.sx index cdcfd8e..780eb9a 100644 --- a/examples/0162-types-typed-module-const-roundtrip.sx +++ b/examples/0162-types-typed-module-const-roundtrip.sx @@ -7,23 +7,30 @@ // - null → pointer (`P : *void : null`) // - integer EXPRESSION → integer (`KE : s64 : M + 2`) — usable as a count too // - integer EXPRESSION → float (`WE : f32 : M + 2`) +// - MIXED int+float EXPRESSION → float (`MF : f64 : M + 0.5`, both operand orders) // // Companion to the negative example 1143: the issue-0088 fix rejects a typed // const whose initializer mismatches its annotation, and these correctly-typed // consts must keep working (no over-rejection) — including const-EXPRESSION // initializers, whose type-based validation (attempt 2) must accept a correctly // typed expression even though it isn't a literal. +// +// `MF`/`MFR` pin the attempt-3 inferExprType promotion fix: a mixed int+float +// arithmetic expression infers as the float result regardless of operand order, +// so it matches an `f64` annotation (and folds to 2.5, not a truncated 2). #import "modules/std.sx"; M :: 2; -K : s64 : 4; -W : f32 : 800; -PI : f32 : 3.14159; -S : string : "hi"; -P : *void : null; -KE : s64 : M + 2; -WE : f32 : M + 2; +K : s64 : 4; +W : f32 : 800; +PI : f32 : 3.14159; +S : string : "hi"; +P : *void : null; +KE : s64 : M + 2; +WE : f32 : M + 2; +MF : f64 : M + 0.5; +MFR : f64 : 0.5 + M; main :: () { // Integer const: prints AND drives an array dimension (len 4). @@ -44,4 +51,8 @@ main :: () { // Integer const-EXPRESSION: prints AND drives an array dimension (len 4). b : [KE]s64 = ---; print("KE={} len={} WE={}\n", KE, b.len, WE); + + // Mixed int+float const-EXPRESSION folds to the promoted float (2.5), + // operand-order-independent. + print("MF={} MFR={}\n", MF, MFR); } diff --git a/examples/0163-types-mixed-numeric-promotion.sx b/examples/0163-types-mixed-numeric-promotion.sx new file mode 100644 index 0000000..f59d8df --- /dev/null +++ b/examples/0163-types-mixed-numeric-promotion.sx @@ -0,0 +1,35 @@ +// Mixed int+float arithmetic infers as the FLOAT result, operand-order-independent. +// +// `print("{}", expr)` selects integer- vs float-formatting from the STATIC type +// `inferExprType` reports for the argument (not the lowered value's type), so it +// exercises the binary-op inference arm directly — distinct from the typed-const +// validation path. Before the attempt-3 fix, binary-op inference was LHS-biased: +// `n + 0.5` (int LHS) inferred `s64` and printed a truncated `2`, while `0.5 + n` +// (float LHS) inferred `f64` and printed `2.5`. The fix routes both through the +// shared promotion rule (`Lowering.arithResultType`, the same one `lowerBinaryOp` +// applies for the value), so an int operand with a float operand promotes to the +// float in either order. +// +// Regression (issue 0088, attempt 3 — the inferExprType numeric-promotion root fix). + +#import "modules/std.sx"; + +main :: () { + n := 2; // runtime s64 + + // Addition, both operand orders — both promote to f64 → 2.5. + print("add: {} {}\n", n + 0.5, 0.5 + n); + + // Multiplication, both orders — both promote → 3.0. + print("mul: {} {}\n", n * 1.5, 1.5 * n); + + // Subtraction / division with the int on the left. + print("sub: {} div: {}\n", n - 0.5, n / 4.0); + + // f32 operand promotes too (int LHS, f32 RHS). + half : f32 = 0.5; + print("f32: {}\n", n + half); + + // A pure-int expression is unaffected — stays s64, prints as an integer. + print("int: {}\n", n + 3); +} diff --git a/examples/1143-diagnostics-typed-module-const-mismatch.sx b/examples/1143-diagnostics-typed-module-const-mismatch.sx index 9b1748a..f8b3b50 100644 --- a/examples/1143-diagnostics-typed-module-const-mismatch.sx +++ b/examples/1143-diagnostics-typed-module-const-mismatch.sx @@ -10,17 +10,25 @@ // validation is type-based, so a const-EXPRESSION initializer (`E : string : // M + 2`, `V : string : -M`) is rejected just like a literal — not skipped // because its node kind isn't a literal (issue 0088, attempt 2). +// +// The mixed-numeric pair (`s64 : M + 0.5`, `s64 : 0.5 + M`) is rejected in BOTH +// operand orders: arithmetic binary-op inference now promotes int+float to the +// float result (`Lowering.arithResultType`), so an s64 annotation no longer +// matches a float-producing initializer regardless of which operand is the +// float (issue 0088, attempt 3 — the inferExprType promotion root fix). #import "modules/std.sx"; M :: 2; -N : string : 4; // integer literal where a string is annotated -F : s64 : "x"; // string literal where an integer is annotated -B : s64 : true; // boolean literal where an integer is annotated -G : s64 : 1.5; // float literal where an integer is annotated -E : string : M + 2; // integer EXPRESSION where a string is annotated -V : string : -M; // integer (unary) expression where a string is annotated +N : string : 4; // integer literal where a string is annotated +F : s64 : "x"; // string literal where an integer is annotated +B : s64 : true; // boolean literal where an integer is annotated +G : s64 : 1.5; // float literal where an integer is annotated +E : string : M + 2; // integer EXPRESSION where a string is annotated +V : string : -M; // integer (unary) expression where a string is annotated +BAD : s64 : M + 0.5; // mixed int+float (int LHS) → f64, rejected vs s64 +BAD2 : s64 : 0.5 + M; // mixed float+int (float LHS) → f64, rejected vs s64 — order-independent main :: () { print("unreachable\n"); diff --git a/examples/expected/0162-types-typed-module-const-roundtrip.stdout b/examples/expected/0162-types-typed-module-const-roundtrip.stdout index 2368e62..aac2be0 100644 --- a/examples/expected/0162-types-typed-module-const-roundtrip.stdout +++ b/examples/expected/0162-types-typed-module-const-roundtrip.stdout @@ -3,3 +3,4 @@ W=800.000000 PI=3.141590 S=hi P_is_null=true KE=4 len=4 WE=4.000000 +MF=2.500000 MFR=2.500000 diff --git a/examples/expected/0163-types-mixed-numeric-promotion.exit b/examples/expected/0163-types-mixed-numeric-promotion.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0163-types-mixed-numeric-promotion.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0163-types-mixed-numeric-promotion.stderr b/examples/expected/0163-types-mixed-numeric-promotion.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0163-types-mixed-numeric-promotion.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0163-types-mixed-numeric-promotion.stdout b/examples/expected/0163-types-mixed-numeric-promotion.stdout new file mode 100644 index 0000000..1424183 --- /dev/null +++ b/examples/expected/0163-types-mixed-numeric-promotion.stdout @@ -0,0 +1,5 @@ +add: 2.500000 2.500000 +mul: 3.000000 3.000000 +sub: 1.500000 div: 0.500000 +f32: 2.500000 +int: 5 diff --git a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr index c997a24..8b93822 100644 --- a/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr +++ b/examples/expected/1143-diagnostics-typed-module-const-mismatch.stderr @@ -1,35 +1,47 @@ error: type mismatch: constant 'N' is declared 'string' but its initializer is an integer literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:18:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:24:15 | -18 | N : string : 4; // integer literal where a string is annotated - | ^ +24 | N : string : 4; // integer literal where a string is annotated + | ^ error: type mismatch: constant 'F' is declared 's64' but its initializer is a string literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:19:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:25:15 | -19 | F : s64 : "x"; // string literal where an integer is annotated - | ^^^ +25 | F : s64 : "x"; // string literal where an integer is annotated + | ^^^ error: type mismatch: constant 'B' is declared 's64' but its initializer is a boolean literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:20:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:26:15 | -20 | B : s64 : true; // boolean literal where an integer is annotated - | ^^^^ +26 | B : s64 : true; // boolean literal where an integer is annotated + | ^^^^ error: type mismatch: constant 'G' is declared 's64' but its initializer is a float literal - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:21:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:27:15 | -21 | G : s64 : 1.5; // float literal where an integer is annotated - | ^^^ +27 | G : s64 : 1.5; // float literal where an integer is annotated + | ^^^ error: type mismatch: constant 'E' is declared 'string' but its initializer is an integer expression - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:22:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:28:15 | -22 | E : string : M + 2; // integer EXPRESSION where a string is annotated - | ^^^^^ +28 | E : string : M + 2; // integer EXPRESSION where a string is annotated + | ^^^^^ error: type mismatch: constant 'V' is declared 'string' but its initializer is an integer expression - --> examples/1143-diagnostics-typed-module-const-mismatch.sx:23:14 + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:29:15 | -23 | V : string : -M; // integer (unary) expression where a string is annotated - | ^^ +29 | V : string : -M; // integer (unary) expression where a string is annotated + | ^^ + +error: type mismatch: constant 'BAD' is declared 's64' but its initializer is a floating-point expression + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:30:15 + | +30 | BAD : s64 : M + 0.5; // mixed int+float (int LHS) → f64, rejected vs s64 + | ^^^^^^^ + +error: type mismatch: constant 'BAD2' is declared 's64' but its initializer is a floating-point expression + --> examples/1143-diagnostics-typed-module-const-mismatch.sx:31:15 + | +31 | BAD2 : s64 : 0.5 + M; // mixed float+int (float LHS) → f64, rejected vs s64 — order-independent + | ^^^^^^^ diff --git a/issues/0088-typed-module-const-annotation-mismatch.md b/issues/0088-typed-module-const-annotation-mismatch.md index b921f4e..1cd2d12 100644 --- a/issues/0088-typed-module-const-annotation-mismatch.md +++ b/issues/0088-typed-module-const-annotation-mismatch.md @@ -15,6 +15,22 @@ > (`M :: 2; N : string : M + 2`, `V : string : -M`) are rejected — the validation > is type-based, so a non-literal node kind can no longer escape it (attempt 2). > +> **Mixed-numeric escape closed at the type-system root (attempt 3).** The +> type-based validation reuses `inferExprType`, which inferred a non-comparison +> binary op from its LHS alone — so `BAD : s64 : M + 0.5` (s64 + f64) inferred +> `s64` and was accepted+truncated, while `0.5 + M` inferred `f64` and was +> rejected: operand-order-dependent. The fix is in the binary-op arm of +> `ExprTyper.inferType` (`src/ir/expr_typer.zig`): arithmetic / bitwise / shift +> ops now infer the PROMOTED result of `(lhs, rhs)` via `Lowering.arithResultType` +> — the same int×float → float rule `lowerBinaryOp` already applied for the +> value (extracted from its inline block into a shared helper, so the two can't +> diverge). `M + 0.5` now infers `f64` in either operand order, so the typed-const +> validation rejects it against an `s64` annotation with no special-casing in the +> validation logic itself. This was a pre-existing inference bug broader than +> typed consts (it also mis-formatted `print("{}", M + 0.5)` as a truncated int); +> the typed-LOCAL `y : s64 = 1.5` → 1 narrowing is a SEPARATE assignment-coercion +> bug tracked as issue 0095. +> > **Fix per file.** > - `src/ir/lower.zig` — `registerTypedModuleConst` validates the initializer > against the resolved annotation BY TYPE, covering literals AND @@ -39,17 +55,25 @@ > updated. > > **Regression tests.** -> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: six +> - `examples/1143-diagnostics-typed-module-const-mismatch.sx` — negative: eight > mismatch shapes — four literal (`int→string`, `string→s64`, `bool→s64`, -> `float→s64`) and two const-expression (`M + 2 → string`, `-M → string`) — -> each emit a `type mismatch` diagnostic, exit 1. +> `float→s64`), two const-expression (`M + 2 → string`, `-M → string`), and two +> mixed-numeric (`s64 : M + 0.5` and `s64 : 0.5 + M`, rejected in BOTH operand +> orders) — each emit a `type mismatch` diagnostic, exit 1. > - `examples/0162-types-typed-module-const-roundtrip.sx` — positive: valid typed > consts (`s64` as count + printed, `f32` from int, `f32` float, `string`, -> `*void` null, plus const-expression `s64 : M + 2` used as a count + printed -> and `f32 : M + 2`) compile, fold, and print correctly. +> `*void` null, const-expression `s64 : M + 2` used as a count + printed, +> `f32 : M + 2`, plus mixed-numeric `f64 : M + 0.5` and `f64 : 0.5 + M` folding +> to 2.5 in both orders) compile, fold, and print correctly. +> - `examples/0163-types-mixed-numeric-promotion.sx` — positive: pins the +> inferExprType promotion DIRECTLY in a value context (`print("{}", n + 0.5)` +> formats as the float `2.5`, both operand orders, across `+ - * /` and an f32 +> operand; a pure-int expression stays an integer). > - `src/ir/program_index.test.zig` — `moduleConstInt gates the fold on the > declared type, not the initializer node` (covers both a literal and a > binary_op value node declared with a non-numeric type). +> - `src/ir/expr_typer.test.zig` — `arithResultType promotes int×float to the +> float regardless of operand order` (the shared promotion helper). # 0088 — Typed module const annotation mismatch is accepted diff --git a/readme.md b/readme.md index 1c54c60..c49d890 100644 --- a/readme.md +++ b/readme.md @@ -118,7 +118,10 @@ A typed constant's initializer must be compatible with its annotation — an integer fits any integer or float, a float a float type, a string `string`, `null` a pointer/optional. The check is type-based, so it covers a literal and a constant expression alike: both `N : string : 4` and `N : string : M + 2` are a -compile-time `type mismatch` error, not a silently-accepted constant. +compile-time `type mismatch` error, not a silently-accepted constant. Mixed +int+float arithmetic promotes to the float in either operand order (`n + 0.5` and +`0.5 + n` are both `f64`), so `C : s64 : M + 0.5` is rejected regardless of order +while `F : f64 : M + 0.5` folds to `2.5`. Builtin type names (`s2`, `u8`, `bool`, `string`, …) are reserved and a *bare* spelling can't be used as an identifier at a **value-binding or declaration-name** diff --git a/specs.md b/specs.md index 14a0c00..ffe9320 100644 --- a/specs.md +++ b/specs.md @@ -1465,6 +1465,10 @@ boolean `bool`, a string `string`, `null` a pointer or optional, and `---` any type. The check is type-based, so it applies equally to a literal and to a constant expression: both `N : string : 4` and `N : string : M + 2` (with `M :: 2`) are rejected at the declaration — neither registers a usable constant. +A constant expression's type is its promoted result type (see +[Arithmetic](#arithmetic)), so a mixed int+float initializer is a float in either +operand order: `C : s64 : M + 0.5` and `C : s64 : 0.5 + M` are both rejected, and +`F : f64 : M + 0.5` is accepted and folds to `2.5`. ### Variable Binding (mutable) @@ -1758,6 +1762,15 @@ x * x x + 2 ``` +**Numeric promotion.** When the two operands of an arithmetic op have different +numeric types, the result is the promoted type: an integer operand combined with +a floating-point operand yields the **float**, regardless of operand order +(`n + 0.5` and `0.5 + n` both produce an `f64`). This holds for the expression's +static type as well as its value, so `print("{}", n + 0.5)` formats a float and a +typed binding `x : f64 = n + 0.5` is exact (not truncated). A mixed-numeric +expression therefore does not satisfy an integer annotation — `C : s64 : n + 0.5` +is a `type mismatch` in either operand order. + ### Chained Comparisons Comparison operators can be chained. Each operand is evaluated exactly once. ```sx diff --git a/src/ir/expr_typer.test.zig b/src/ir/expr_typer.test.zig index b267ada..cabebe3 100644 --- a/src/ir/expr_typer.test.zig +++ b/src/ir/expr_typer.test.zig @@ -34,7 +34,7 @@ test "expr_typer: literal shapes" { try std.testing.expectEqual(TypeId.string, l.inferExprType(&str_n)); } -test "expr_typer: binary comparison is bool, arithmetic takes lhs type" { +test "expr_typer: binary comparison is bool, int arithmetic stays int" { const alloc = std.testing.allocator; var module = ir_mod.Module.init(alloc); defer module.deinit(); @@ -50,6 +50,46 @@ test "expr_typer: binary comparison is bool, arithmetic takes lhs type" { try std.testing.expectEqual(TypeId.s64, l.inferExprType(&add)); } +// issue 0088 (attempt 3): a non-comparison binary op infers the PROMOTED result +// of (lhs, rhs), not the LHS alone — so a mixed int+float op types as the float +// in EITHER operand order (was LHS-biased: `int + float` → s64 while +// `float + int` → f64). This is what feeds the typed-const validation that +// rejected `s64 : 0.5 + M` but not `s64 : M + 0.5`. +test "expr_typer: mixed int+float arithmetic promotes to float, order-independent" { + const alloc = std.testing.allocator; + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var l = Lowering.init(&module); + + var int_n = node(.{ .int_literal = .{ .value = 2 } }); + var float_n = node(.{ .float_literal = .{ .value = 0.5 } }); + + // int LHS, float RHS → f64 (was s64 before the fix). + var add_if = node(.{ .binary_op = .{ .op = .add, .lhs = &int_n, .rhs = &float_n } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&add_if)); + + // float LHS, int RHS → f64 (already correct; confirms order-independence). + var add_fi = node(.{ .binary_op = .{ .op = .add, .lhs = &float_n, .rhs = &int_n } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&add_fi)); + + // Multiplication promotes the same way. + var mul_if = node(.{ .binary_op = .{ .op = .mul, .lhs = &int_n, .rhs = &float_n } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&mul_if)); +} + +// The shared promotion helper itself (single source of truth for both +// `lowerBinaryOp`'s value type and `inferExprType`): an integer LHS with a +// floating-point RHS promotes to the float; every other pairing keeps the LHS. +test "arithResultType: int×float promotes to float, else takes lhs" { + try std.testing.expectEqual(TypeId.f64, Lowering.arithResultType(.s64, .f64)); + try std.testing.expectEqual(TypeId.f32, Lowering.arithResultType(.u32, .f32)); + try std.testing.expectEqual(TypeId.f32, Lowering.arithResultType(.s64, .f32)); + // Non-promoting pairings keep the LHS type. + try std.testing.expectEqual(TypeId.s64, Lowering.arithResultType(.s64, .s64)); + try std.testing.expectEqual(TypeId.f64, Lowering.arithResultType(.f64, .s64)); + try std.testing.expectEqual(TypeId.f32, Lowering.arithResultType(.f32, .f64)); +} + test "expr_typer: unary not is bool, negate preserves operand type" { const alloc = std.testing.allocator; var module = ir_mod.Module.init(alloc); diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index f9a1803..364cd78 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -49,7 +49,12 @@ pub const ExprTyper = struct { break :blk .bool; }, .eq, .neq, .lt, .lte, .gt, .gte, .and_op, .in_op => .bool, - else => self.l.inferExprType(bop.lhs), + // Arithmetic / bitwise / shift ops: infer the PROMOTED result + // of (lhs, rhs), not the LHS alone — `Lowering.arithResultType` + // is the same rule `lowerBinaryOp` applies, so `M + 0.5` types + // as `f64` regardless of operand order (was LHS-biased: `M + 0.5` + // → s64 while `0.5 + M` → f64). + else => Lowering.arithResultType(self.l.inferExprType(bop.lhs), self.l.inferExprType(bop.rhs)), }, .unary_op => |uop| switch (uop.op) { .not => .bool, diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 46d3f80..c031547 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -3263,19 +3263,12 @@ pub const Lowering = struct { const rhs_ref_pointee = self.refCapturePointee(bop.rhs); if (rhs_ref_pointee) |p| rhs = self.builder.load(rhs, p); self.target_type = saved_tt; - // Infer result type from LHS operand (covers float, bool, etc.) - var ty = lhs_ty; - - // Promote int×float → float (e.g., s64 * f32 → f32) - // Only for scalar int LHS — don't affect vectors or structs. - { - const rhs_inferred = rhs_ref_pointee orelse self.inferExprType(bop.rhs); - const l_int = isInt(ty); - const r_float = (rhs_inferred == .f32 or rhs_inferred == .f64); - if (l_int and r_float) { - ty = rhs_inferred; - } - } + // Result type follows the shared promotion rule: an int LHS with a + // float RHS promotes to the float (`s64 * f32` → `f32`); vectors / + // structs keep the LHS type. `inferExprType` reuses the same helper + // so static typing agrees with the value produced here. + const rhs_inferred = rhs_ref_pointee orelse self.inferExprType(bop.rhs); + var ty = arithResultType(lhs_ty, rhs_inferred); // Auto-unwrap optional operands for arithmetic/comparison if (!ty.isBuiltin()) { @@ -14528,6 +14521,20 @@ pub const Lowering = struct { return ty == .f32 or ty == .f64; } + /// Result type of an arithmetic / bitwise / shift binary op over two + /// scalar operand types. This is the single promotion rule shared by the + /// value path (`lowerBinaryOp`) and AST-level inference + /// (`ExprTyper.inferType`'s binary-op arm), so static typing reports + /// exactly the type the lowered value carries. An integer LHS with a + /// floating-point RHS promotes to the float (`s64 + f64` → `f64`); every + /// other pairing — including vectors / structs, whose `isInt` is false — + /// takes the LHS type. Comparison / logical ops never reach here (they + /// are `.bool` at both sites). + pub fn arithResultType(lhs_ty: TypeId, rhs_ty: TypeId) TypeId { + if (isInt(lhs_ty) and isFloat(rhs_ty)) return rhs_ty; + return lhs_ty; + } + fn isInt(ty: TypeId) bool { return switch (ty) { .s8, .s16, .s32, .s64, .u8, .u16, .u32, .u64, .usize, .isize => true,