fix(ir): infer mixed int+float arithmetic as the promoted float [F0.7]

`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.
This commit is contained in:
agra
2026-06-05 08:23:59 +03:00
parent 454ea06bd4
commit b69ec43ba3
14 changed files with 218 additions and 52 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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");

View File

@@ -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

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -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

View File

@@ -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
| ^^^^^^^

View File

@@ -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

View File

@@ -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**

View File

@@ -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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,