From e442cdf5e7f021571bdf46e45d018f944096e3b0 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 19:26:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(ir):=20float=20`/`=20folds=20as=20FLOAT=20d?= =?UTF-8?q?ivision=20under=20the=20unified=20narrowing=20rule=20=E2=80=94?= =?UTF-8?q?=20int=20folder=20refuses=20a=20float-operand=20`/`=20[F0.11]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared compile-time integer folder (`evalConstIntExpr`) accepts an integral float literal/const as an integer leaf (`[4.0]` → 4) and then applied INTEGER arithmetic to the whole expression — so `5.0 / 2.0` folded as `divTrunc(5,2)` = 2 instead of float division (`2.5`). The bug fired at all FIVE unified-rule sites (typed local, field default, param default, typed const, array dimension), because the typed sites evaluate through `evalConstFloatExpr` (which delegates the node to the int folder) and the count sites through `foldCountI64` (int folder first). Fix at the single root: `evalConstIntExpr`'s `.div` arm refuses to fold a division whose lhs/rhs is float-valued (`isFloatValuedExpr`), so the value surfaces through `evalConstFloatExpr` + the unified rule — an integral quotient (`6.0 / 2.0` → 3) folds, a non-integral one (`5.0 / 2.0` = 2.5, mixed `5 / 2.0`, float-const `F / G`) errors. Genuine integer `/` (`5 / 2` → 2) is unchanged; `*`/`+`/`-` need no guard (they agree between int and float for the integral operands the int folder ever sees). `isFloatValuedExpr` judges a const-leaf by VALUE (`moduleConstIsFloatTyped` recurses into the const's value with the existing cycle-guard frame), so an untyped float-EXPRESSION const (`ME :: 4.0 + 1.0`, placeholder type s64) is caught at both the count path and — via `foldComptimeFloatInit`'s guard — the typed-binding path. A backtick RAW receiver (`` `f64.epsilon ``) is a field read, not a float limit (is_raw check, issues 0092/0093). Regression: examples/1147 (negative — `5.0 / 2.0` errors at all five sites plus untyped float-EXPR const div); 0168 extended (positive — `6.0 / 2.0`, `12.0 / 4.0`, `[6.0/2.0]`, `xx (5.0/2.0)` → 2); unit tests "the int folder refuses a FLOAT division" and "moduleConstIsFloatTyped judges a const by VALUE". specs.md + readme.md state the float-`/` rule. --- examples/0168-types-integral-float-to-int.sx | 21 +++- ...47-diagnostics-float-division-narrowing.sx | 46 +++++++ .../0168-types-integral-float-to-int.stdout | 6 +- ...-diagnostics-float-division-narrowing.exit | 1 + ...iagnostics-float-division-narrowing.stderr | 41 +++++++ ...iagnostics-float-division-narrowing.stdout | 0 .../0095-typed-local-float-int-narrowing.md | 24 ++++ readme.md | 6 +- specs.md | 19 ++- src/ir/lower.zig | 18 ++- src/ir/program_index.test.zig | 115 +++++++++++++++++- src/ir/program_index.zig | 104 +++++++++++++++- src/ir/type_bridge.zig | 10 ++ 13 files changed, 393 insertions(+), 18 deletions(-) create mode 100644 examples/1147-diagnostics-float-division-narrowing.sx create mode 100644 examples/expected/1147-diagnostics-float-division-narrowing.exit create mode 100644 examples/expected/1147-diagnostics-float-division-narrowing.stderr create mode 100644 examples/expected/1147-diagnostics-float-division-narrowing.stdout diff --git a/examples/0168-types-integral-float-to-int.sx b/examples/0168-types-integral-float-to-int.sx index bdd67bf..5e8f9ad 100644 --- a/examples/0168-types-integral-float-to-int.sx +++ b/examples/0168-types-integral-float-to-int.sx @@ -30,6 +30,7 @@ Box :: struct { n : s64 = 4.0; // integral float field default → folds to 4 ne : s64 = M + 2.0; // integral int-const-EXPR field default → folds to 4 nf : s64 = F + 1.5; // integral float-const-LEAF field default → folds to 4 + nd : s64 = 8.0 / 2.0; // integral float-DIVISION field default → folds to 4 } withDefault :: (x : s64 = 6.0) -> s64 { return x; } // param default → 6 @@ -37,6 +38,7 @@ withFlt :: (x : s64 = F + 1.5) -> s64 { return x; } // float-const-leaf pa K : s64 : 8.0; // integral float module const → folds to 8 KF : s64 : F + 1.5; // integral float-const-LEAF module const → folds to 4 +KD : s64 : 12.0 / 4.0; // integral float-DIVISION module const → folds to 3 ArrFE :: [F + 1.5]s64; // array-dim type ALIAS over a float-const-leaf expr → [4]s64 // (the stateless registration path must agree with the @@ -53,9 +55,16 @@ main :: () { neg : s64 = -2.0; print("neg={}\n", neg); - // Struct field defaults fold (literal + int-const expr + float-const leaf). + // Integral float DIVISION folds (the subtle case: integral operands, but the + // `/` is float division). `6.0 / 2.0` = 3.0 → 3; the int folder refuses the + // float `/` and the unified rule folds the integral result. + zd : s64 = 6.0 / 2.0; + print("localDiv={}\n", zd); + + // Struct field defaults fold (literal + int-const expr + float-const leaf + + // float division). b := Box.{}; - print("field={} fieldExpr={} fieldFlt={}\n", b.n, b.ne, b.nf); + print("field={} fieldExpr={} fieldFlt={} fieldDiv={}\n", b.n, b.ne, b.nf, b.nd); // Param defaults fold. print("param={} paramFlt={}\n", withDefault(), withFlt()); @@ -64,6 +73,11 @@ main :: () { a : [K]s64 = ---; print("const={} constFlt={} len={}\n", K, KF, a.len); + // Integral float-DIVISION const folds, and drives an array dimension directly + // (`[6.0 / 2.0]` → len 3) through the SAME refuse-int-fold / fold-float rule. + ad2 : [6.0 / 2.0]s64 = ---; + print("constDiv={} dimDiv={}\n", KD, ad2.len); + // Array DIMENSION — the fifth site joins the unified rule: an integral // float-const-leaf expression folds to a count whether written DIRECTLY // (`[F + 1.5]` → 4), THROUGH a float-expr const (`[KF]`, KF = F + 1.5 = 4), @@ -100,5 +114,6 @@ main :: () { xf : s64 = xx (F + 0.25); xl : s64 = xx (f64.true_min + 0.5); xm : s64 = xx (5.5 % 2.0); - print("xx={} cast={} xxExpr={} xxFlt={} xxLimit={} xxMod={}\n", e, c, xc, xf, xl, xm); + xd : s64 = xx (5.0 / 2.0); // non-integral float DIVISION → truncates to 2 + print("xx={} cast={} xxExpr={} xxFlt={} xxLimit={} xxMod={} xxDiv={}\n", e, c, xc, xf, xl, xm, xd); } diff --git a/examples/1147-diagnostics-float-division-narrowing.sx b/examples/1147-diagnostics-float-division-narrowing.sx new file mode 100644 index 0000000..fe20d88 --- /dev/null +++ b/examples/1147-diagnostics-float-division-narrowing.sx @@ -0,0 +1,46 @@ +// Unified float→int narrowing rule (F0.11), float-DIVISION pin: a compile-time +// float division (`5.0 / 2.0` = 2.5) is a NON-INTEGRAL float, so narrowing it +// implicitly into an integer-typed binding is a COMPILE ERROR — exactly like any +// other non-integral float (example 1146). The division is the subtle case: its +// operands (`5.0`, `2.0`) are individually INTEGRAL, so a naive integer fold +// would truncate `5.0 / 2.0` to 2 with no diagnostic. The rule fires at all five +// sites — a typed module CONST, a struct FIELD default, a function PARAM default, +// a typed LOCAL, and an array DIMENSION — because the shared compile-time integer +// folder now refuses a division with a float operand, deferring it to the float +// evaluator + the unified rule (integral folds, non-integral errors). A float +// operand on either side (literal or float-typed const) makes the `/` a float +// division. +// +// The escape hatch stays open: `xx (5.0 / 2.0)` truncates to 2 with no error, and +// an INTEGRAL float division (`6.0 / 2.0` → 3) folds — both exercised on the +// POSITIVE side (example 0168). +// +// Regression (issue 0095, F0.11-6): `5.0 / 2.0` at a typed local, field default, +// param default, typed const, and array dimension all silently folded to 2 via +// integer truncating division; each now rejects the non-integral float. +#import "modules/std.sx"; + +// An UNTYPED float-EXPRESSION const carries a placeholder `s64` type, yet its +// value is float — `ME / 2` is still float division and must reject (judged by +// the const's VALUE, not its declared type), at BOTH the typed-binding path and +// the count path. +ME :: 4.0 + 1.0; // untyped float-EXPRESSION const (= 5.0) + +// Typed CONST: declared but not referenced, so the single narrowing error is not +// followed by a downstream "unresolved const" cascade. +K : s64 : 5.0 / 2.0; // 2.5 non-integral float-DIVISION const → error + +BadField :: struct { + f : s64 = 5.0 / 2.0; // non-integral float-DIVISION field default → error +} + +badParam :: (x : s64 = 5.0 / 2.0) -> s64 { return x; } // float-DIVISION param default → error + +main :: () { + local : s64 = 5.0 / 2.0; // non-integral float-DIVISION local → error + dim : [5.0 / 2.0]s64 = ---; // non-integral float-DIVISION array dimension → error + cdiv : s64 = ME / 2; // untyped float-EXPR const division (5.0/2 = 2.5) → error + cdim : [ME / 2]s64 = ---; // same, at the count path → error + b := BadField.{}; + print("{} {} {} {} {} {}\n", local, b.f, badParam(), dim.len, cdiv, cdim.len); +} diff --git a/examples/expected/0168-types-integral-float-to-int.stdout b/examples/expected/0168-types-integral-float-to-int.stdout index 9782e35..7b1bdeb 100644 --- a/examples/expected/0168-types-integral-float-to-int.stdout +++ b/examples/expected/0168-types-integral-float-to-int.stdout @@ -1,9 +1,11 @@ local=4 localExpr=4 localFlt=4 neg=-2 -field=4 fieldExpr=4 fieldFlt=4 +localDiv=3 +field=4 fieldExpr=4 fieldFlt=4 fieldDiv=4 param=6 paramFlt=4 const=8 constFlt=4 len=8 +constDiv=3 dimDiv=3 dim.direct=4 dim.const=4 dim.alias=4 limit=0 fmod=2 intlimit=127 intcount=255 -xx=4 cast=1 xxExpr=2 xxFlt=2 xxLimit=0 xxMod=1 +xx=4 cast=1 xxExpr=2 xxFlt=2 xxLimit=0 xxMod=1 xxDiv=2 diff --git a/examples/expected/1147-diagnostics-float-division-narrowing.exit b/examples/expected/1147-diagnostics-float-division-narrowing.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1147-diagnostics-float-division-narrowing.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1147-diagnostics-float-division-narrowing.stderr b/examples/expected/1147-diagnostics-float-division-narrowing.stderr new file mode 100644 index 0000000..962bf4c --- /dev/null +++ b/examples/expected/1147-diagnostics-float-division-narrowing.stderr @@ -0,0 +1,41 @@ +error: cannot implicitly narrow non-integral float '2.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1147-diagnostics-float-division-narrowing.sx:31:11 + | +31 | K : s64 : 5.0 / 2.0; // 2.5 non-integral float-DIVISION const → error + | ^^^^^^^^^ + +error: cannot implicitly narrow non-integral float '2.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1147-diagnostics-float-division-narrowing.sx:40:19 + | +40 | local : s64 = 5.0 / 2.0; // non-integral float-DIVISION local → error + | ^^^^^^^^^ + +error: array dimension must be an integer, but '2.5' is a non-integral float + --> examples/1147-diagnostics-float-division-narrowing.sx:41:12 + | +41 | dim : [5.0 / 2.0]s64 = ---; // non-integral float-DIVISION array dimension → error + | ^^^^^^^^^ + +error: cannot implicitly narrow non-integral float '2.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1147-diagnostics-float-division-narrowing.sx:42:18 + | +42 | cdiv : s64 = ME / 2; // untyped float-EXPR const division (5.0/2 = 2.5) → error + | ^^^^^^ + +error: array dimension must be an integer, but '2.5' is a non-integral float + --> examples/1147-diagnostics-float-division-narrowing.sx:43:13 + | +43 | cdim : [ME / 2]s64 = ---; // same, at the count path → error + | ^^^^^^ + +error: cannot implicitly narrow non-integral float '2.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1147-diagnostics-float-division-narrowing.sx:34:15 + | +34 | f : s64 = 5.0 / 2.0; // non-integral float-DIVISION field default → error + | ^^^^^^^^^ + +error: cannot implicitly narrow non-integral float '2.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1147-diagnostics-float-division-narrowing.sx:37:24 + | +37 | badParam :: (x : s64 = 5.0 / 2.0) -> s64 { return x; } // float-DIVISION param default → error + | ^^^^^^^^^ diff --git a/examples/expected/1147-diagnostics-float-division-narrowing.stdout b/examples/expected/1147-diagnostics-float-division-narrowing.stdout new file mode 100644 index 0000000..e69de29 diff --git a/issues/0095-typed-local-float-int-narrowing.md b/issues/0095-typed-local-float-int-narrowing.md index 3b6ba88..c2945ba 100644 --- a/issues/0095-typed-local-float-int-narrowing.md +++ b/issues/0095-typed-local-float-int-narrowing.md @@ -141,6 +141,30 @@ > f64.max` → 0, `6.0 % 4.0` → 2, integer-limit `s8.max`/`[u8.max]` unregressed, > `xx` escapes for both new forms) and 1146 (negative: `f64.true_min + 0.5` and > `5.5 % 2.0` error at a binding site). +> +> **Completion (F0.11 attempt 6)** — attempt 5 reached evaluator parity for +> leaves/operators, but a structural hole remained in the SHARED integer folder: +> `evalConstIntExpr` accepts an integral float literal/const as an integer leaf +> (`[4.0]` → 4) and then applies INTEGER arithmetic to the whole expression — so a +> float DIVISION with integral-looking operands (`5.0 / 2.0`) folded as integer +> truncating division (`divTrunc(5,2)` = 2) instead of float division (`2.5`). The +> bug fired at ALL FIVE sites (`5.0 / 2.0` printed `2` at a typed local, field +> default, param default, typed const, and array dimension), because the typed +> sites evaluate through `evalConstFloatExpr` (which delegates the whole node to +> the int folder) and the count sites through `foldCountI64` (which tries the int +> folder first). Closed at the single root: `evalConstIntExpr`'s `.div` arm now +> REFUSES to fold a division whose lhs/rhs is float-valued (a new +> `isFloatValuedExpr` predicate, resolving a float-typed const leaf through each +> ctx's `nameIsFloatTyped`) — so the division surfaces through `evalConstFloatExpr` +> (float `/`) + the unified rule: an integral quotient (`6.0 / 2.0` → 3) folds, a +> non-integral one (`5.0 / 2.0` = 2.5, mixed `5 / 2.0`, float-const `F / G`) +> errors. Genuine integer `/` (`5 / 2` → 2) is unchanged; `*`/`+`/`-` need no guard +> (they agree between int and float for the integral operands the int folder ever +> sees). Regression: `examples/1147-diagnostics-float-division-narrowing.sx` +> (negative — `5.0 / 2.0` errors at all five sites), the integral-`/` positives +> added to `examples/0168` (`6.0 / 2.0` local/field, `12.0 / 4.0` const, `[6.0 / +> 2.0]` dim, `xx (5.0 / 2.0)` → 2), and unit +> `program_index.test.zig` "the int folder refuses a FLOAT division". ## Symptom A typed LOCAL (and likely typed param/field) silently truncates a floating-point diff --git a/readme.md b/readme.md index 6b05fa6..cb6591f 100644 --- a/readme.md +++ b/readme.md @@ -130,8 +130,10 @@ array dimension uses: an **integral** compile-time float folds to its integer, a or *any* compile-time-constant float expression — including one that references a float-typed const (`F : f64 : 2.5; y : s64 = F + 1.5` → `4`), a builtin float numeric-limit accessor (`f64.max - f64.max` → `0`, while `f64.true_min + 0.5` -errors), or a float `%` (`6.0 % 4.0` → `2`, while `5.5 % 2.0` = `1.5` errors): the -compile-time float evaluator recognises every leaf shape the integer one does, so +errors), a float `%` (`6.0 % 4.0` → `2`, while `5.5 % 2.0` = `1.5` errors), or a +float `/` (`6.0 / 2.0` → `3`, while `5.0 / 2.0` = `2.5` errors — a float `/` is +always float division, never integer truncation, even with integral operands): +the compile-time float evaluator recognises every leaf shape the integer one does, so no constant float form escapes the rule at one site while folding at another — and is uniform across a typed local, a parameter default, a struct field default, a call diff --git a/specs.md b/specs.md index c716d68..1e28e80 100644 --- a/specs.md +++ b/specs.md @@ -898,7 +898,10 @@ is written — a literal (`4.0`), a float-typed const (`N : f64 : 4.0`), or a const **expression** whose value is integral, including one built from a non-integral float-const leaf (`F : f64 : 2.5; [F + 1.5]s64` ≡ `[4]s64`, and likewise through a const, `K : s64 : F + 1.5; [K]s64`), a builtin float -numeric-limit accessor (`[f64.max - f64.max]s64` → length 0), or a float `%`. A +numeric-limit accessor (`[f64.max - f64.max]s64` → length 0), a float `%`, or a +float `/` whose quotient is integral (`[6.0 / 2.0]s64` ≡ `[3]s64`; a non-integral +quotient like `[5.0 / 2.0]s64` = 2.5 is rejected — a float `/` is always float +division, never integer truncation, even when both operands are integral). A count and a typed binding's float→integer initializer share the *same* compile-time float evaluation, so they agree at every site — direct, through a const, or via a type @@ -1440,14 +1443,18 @@ array dimension / lane count uses (see "Array dimensions are integral", §2): here is *any* compile-time-constant float expression — an integer-const leaf (`M + 2.0`), a float-typed const leaf (`F : f64 : 2.5; y : s64 = F + 1.5` → 4), a builtin float numeric-limit accessor (`f64.max - f64.max` → 0), a float `%` - (`6.0 % 4.0` → 2), or any combination of them. The compile-time float evaluator - recognises every leaf/operator shape the integer evaluator does (literal, named - const, numeric-limit accessor, `+ - * / %`, unary negate), so no constant float - form folds at one site while truncating at another. + (`6.0 % 4.0` → 2), or a float `/` whose quotient is integral (`6.0 / 2.0` → 3), + or any combination of them. The compile-time float evaluator recognises every + leaf/operator shape the integer evaluator does (literal, named const, + numeric-limit accessor, `+ - * / %`, unary negate), so no constant float form + folds at one site while truncating at another. A float `/` is always FLOAT + division even when both operands are integral — `6.0 / 2.0` is `3.0` (folds), + but `5.0 / 2.0` is `2.5` (errors) — never integer truncating division. - A **non-integral** compile-time float — literal OR const expression — is a **compile error** with one uniform wording at every site: `y : s64 = 1.5`, `y : s64 = M + 0.5`, `y : s64 = F + 0.25` (= 2.75), - `y : s64 = f64.true_min + 0.5` (= 0.5), and `y : s64 = 5.5 % 2.0` (= 1.5) all → + `y : s64 = f64.true_min + 0.5` (= 0.5), `y : s64 = 5.5 % 2.0` (= 1.5), and + `y : s64 = 5.0 / 2.0` (= 2.5) all → "cannot implicitly narrow non-integral float '…' to 's64'; use an explicit cast (`xx`/`cast`)". - This applies uniformly to a typed **local**, a function **param default**, a diff --git a/src/ir/lower.zig b/src/ir/lower.zig index a630447..28f7757 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -12137,6 +12137,16 @@ pub const Lowering = struct { return program_index_mod.moduleConstFloat(&self.program_index.module_const_map, &self.module.types, name); } + /// True iff `name` is a FLOAT-valued module const (`F : f64 : 2.5`, + /// `K : f64 : 4.0`, untyped `M :: 4.0`, untyped-EXPR `ME :: 4.0 + 1.0`). The + /// int folder's division arm consults this so a `/` with a float-const operand + /// is recognised as float division (issue 0095 / F0.11-6). Comptime / generic + /// value bindings are always integer-valued, so only the module-const table + /// can name a float. + pub fn nameIsFloatTyped(self: *Lowering, name: []const u8) bool { + return program_index_mod.moduleConstIsFloatTyped(&self.program_index.module_const_map, &self.module.types, name); + } + /// Resolve a name to a compile-time integer across the three const tables. fn comptimeIntNamed(self: *Lowering, name: []const u8) ?i64 { if (self.comptime_constants.get(name)) |cv| switch (cv) { @@ -14644,8 +14654,12 @@ pub const Lowering = struct { // Only a FLOAT-flavored initializer narrows here; a plain comptime int // (`5`, `M + 2`) is left to the normal integer path. Safe to infer now — // `evalConstFloatExpr` only succeeds for literal / const-arithmetic - // nodes, never an unbound pack index. - if (!isFloat(self.inferExprType(node))) return null; + // nodes, never an unbound pack index. `inferExprType` is the primary + // signal, but it reads a const's DECLARED type — which is a placeholder + // `s64` for an untyped float-EXPRESSION const (`ME :: 4.0 + 1.0`), so + // `ME / 2` would look like integer division; `isFloatValuedExpr` (judging + // by VALUE) catches that case so it narrows under the unified rule too. + if (!isFloat(self.inferExprType(node)) and !program_index_mod.isFloatValuedExpr(node, self)) return null; // Integral comptime float folds to its int (`floatToIntExact`, the same // facility the array-dim / `$K: Count` paths use); a non-integral one is // the narrowing error. diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index dff5337..b339996 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -104,6 +104,10 @@ const DimCtx = struct { pub fn lookupDimName(_: DimCtx, name: []const u8) ?i64 { if (std.mem.eql(u8, name, "M")) return 4; if (std.mem.eql(u8, name, "N")) return 6; + // `K : f64 : 4.0` is an INTEGRAL float const: it folds to 4 through the + // int delegation (`floatToIntExact`) yet stays float-typed — the case the + // division guard must still recognise as float division. + if (std.mem.eql(u8, name, "K")) return 4; return null; } // `xs` stands in for a pack of arity 3; every other name has no pack length. @@ -113,12 +117,19 @@ const DimCtx = struct { } // `F` stands in for a NON-INTEGRAL float module const (`F : f64 : 2.5`): the // int folder cannot resolve it, so only the float-leaf lookup surfaces it. - // Integer consts (`M`/`N`) are resolved by the int delegation and never reach - // this arm; `Z` is genuinely runtime. + // `K` stands in for an INTEGRAL float const (`K : f64 : 4.0`) — it folds to 4 + // through the int delegation yet is still float-typed. Integer consts (`M`/`N`) + // are resolved by the int delegation and never reach this arm; `Z` is runtime. pub fn lookupFloatName(_: DimCtx, name: []const u8) ?f64 { if (std.mem.eql(u8, name, "F")) return 2.5; + if (std.mem.eql(u8, name, "K")) return 4.0; return null; } + // The float-typed-const predicate the division guard consults: `F`/`K` are + // float-typed module consts, every other name is not. + pub fn nameIsFloatTyped(_: DimCtx, name: []const u8) bool { + return std.mem.eql(u8, name, "F") or std.mem.eql(u8, name, "K"); + } }; fn nLit(v: i64) ast.Node { @@ -271,6 +282,53 @@ test "moduleConstInt folds expression-RHS consts and rejects cycles" { try std.testing.expect(pi.moduleConstInt(&map, &table, "C") == null); } +test "moduleConstIsFloatTyped judges a const by VALUE, catching untyped float-EXPR consts" { + var table = types.TypeTable.init(std.testing.allocator); + defer table.deinit(); + var map = std.StringHashMap(pi.ModuleConstInfo).init(std.testing.allocator); + defer map.deinit(); + + // KT : f64 : 4.0 (typed float), MI :: 2 (untyped int), ML :: 5.0 (untyped + // float literal → f64), ME :: 4.0 + 1.0 (untyped float EXPRESSION, placeholder + // type s64 yet float-valued), IE :: 1 + 2 (untyped int expression). + var kt_val = nFloat(4.0); + var mi_val = nLit(2); + var ml_val = nFloat(5.0); + var four = nFloat(4.0); + var one_f = nFloat(1.0); + var me_val = nBin(.add, &four, &one_f); + var l1 = nLit(1); + var l2 = nLit(2); + var ie_val = nBin(.add, &l1, &l2); + try map.put("KT", .{ .value = &kt_val, .ty = .f64 }); + try map.put("MI", .{ .value = &mi_val, .ty = .s64 }); + try map.put("ML", .{ .value = &ml_val, .ty = .f64 }); // pass-0 stores a float literal as f64 + try map.put("ME", .{ .value = &me_val, .ty = .s64 }); // pass-0 placeholder for a binary_op + try map.put("IE", .{ .value = &ie_val, .ty = .s64 }); + + // Float-valued: a typed float const, an untyped float literal, AND an untyped + // float EXPRESSION whose declared type is the s64 placeholder (judged by value). + try std.testing.expect(pi.moduleConstIsFloatTyped(&map, &table, "KT")); + try std.testing.expect(pi.moduleConstIsFloatTyped(&map, &table, "ML")); + try std.testing.expect(pi.moduleConstIsFloatTyped(&map, &table, "ME")); + // NOT float-valued: an int const, an int expression, an absent name. + try std.testing.expect(!pi.moduleConstIsFloatTyped(&map, &table, "MI")); + try std.testing.expect(!pi.moduleConstIsFloatTyped(&map, &table, "IE")); + try std.testing.expect(!pi.moduleConstIsFloatTyped(&map, &table, "absent")); + + // A cyclic const has no value: the frame guard returns false without looping. + var a_id = nIdent("A"); + var b_id = nIdent("B"); + var az = nFloat(0.0); + var a_val = nBin(.add, &b_id, &az); + var b_val = nBin(.add, &a_id, &az); + try map.put("A", .{ .value = &a_val, .ty = .s64 }); + try map.put("B", .{ .value = &b_val, .ty = .s64 }); + // The `+ 0.0` literal still makes them float-valued (a finite, non-cyclic leaf + // is reached before the cycle); the point is it TERMINATES. + try std.testing.expect(pi.moduleConstIsFloatTyped(&map, &table, "A")); +} + test "moduleConstInt gates the fold on the declared type, not the initializer node" { var table = types.TypeTable.init(std.testing.allocator); defer table.deinit(); @@ -446,3 +504,56 @@ test "foldCountI64 / foldDimU32 fold an integral float count, reject a non-integ var negf = nNeg(&f4); // -4.0 → -4 try std.testing.expectEqual(pi.DimU32{ .below_min = -4 }, pi.foldDimU32(&negf, ctx, 0)); } + +test "the int folder refuses a FLOAT division (issue 0095 / F0.11-6)" { + const eval = pi.evalConstIntExpr; + const ctx = DimCtx{}; // K : f64 : 4.0 (integral float const), M = 4 (int const) + + var five = nLit(5); + var two = nLit(2); + var six = nLit(6); + var f5 = nFloat(5.0); + var f2 = nFloat(2.0); + var f6 = nFloat(6.0); + var k = nIdent("K"); // integral float const (folds to 4, yet float-typed) + var m = nIdent("M"); // integer const (4) + + // Genuine INTEGER division still truncates (`5 / 2` → 2, `6 / 2` → 3). + var idiv = nBin(.div, &five, &two); + var idiv2 = nBin(.div, &six, &two); + try std.testing.expectEqual(@as(?i64, 2), eval(&idiv, ctx)); + try std.testing.expectEqual(@as(?i64, 3), eval(&idiv2, ctx)); + + // FLOAT division is REFUSED by the int folder (returns null), even when the + // result is integral (`6.0 / 2.0`) — so it surfaces through the float folder + // + the unified narrowing rule instead of truncating. A float operand on + // either side (literal or float-typed const) is enough. + var fdiv_nonint = nBin(.div, &f5, &f2); // 5.0 / 2.0 = 2.5 + var fdiv_int = nBin(.div, &f6, &f2); // 6.0 / 2.0 = 3.0 (integral, still refused) + var fdiv_mixedl = nBin(.div, &f5, &two); // 5.0 / 2 = 2.5 (mixed promotes to float) + var fdiv_mixedr = nBin(.div, &five, &f2); // 5 / 2.0 = 2.5 + var fdiv_const = nBin(.div, &k, &two); // K / 2 = 4.0/2 = 2.0 (float const, refused) + try std.testing.expect(eval(&fdiv_nonint, ctx) == null); + try std.testing.expect(eval(&fdiv_int, ctx) == null); + try std.testing.expect(eval(&fdiv_mixedl, ctx) == null); + try std.testing.expect(eval(&fdiv_mixedr, ctx) == null); + try std.testing.expect(eval(&fdiv_const, ctx) == null); + + // The float folder recovers the TRUE float value of the refused divisions, so + // the unified rule can fold the integral one and reject the non-integral one. + const evalf = pi.evalConstFloatExpr; + try std.testing.expectEqual(@as(?f64, 2.5), evalf(&fdiv_nonint, ctx)); + try std.testing.expectEqual(@as(?f64, 3.0), evalf(&fdiv_int, ctx)); + try std.testing.expectEqual(@as(?f64, 2.0), evalf(&fdiv_const, ctx)); + // An int-const division (`M / 2` = 4/2) is NOT float division — it truncates. + var mdiv = nBin(.div, &m, &two); + try std.testing.expectEqual(@as(?i64, 2), eval(&mdiv, ctx)); + + // Non-division float arithmetic is unaffected: `*`/`+`/`-` over integral + // operands agree between int and float, so they still fold via the int folder + // (`6.0 * 2.0` → 12, `K - 2.0` → 2). + var fmul = nBin(.mul, &f6, &f2); // 6.0 * 2.0 = 12 + var ksub = nBin(.sub, &k, &f2); // K - 2.0 = 2 + try std.testing.expectEqual(@as(?i64, 12), eval(&fmul, ctx)); + try std.testing.expectEqual(@as(?i64, 2), eval(&ksub, ctx)); +} diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index 0b55c81..c6c20d1 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -106,8 +106,38 @@ const ModuleConstCtx = struct { pub fn lookupFloatName(self: ModuleConstCtx, name: []const u8) ?f64 { return moduleConstFloatFramed(self.consts, self.table, name, self.frame); } + /// True iff `name` names a FLOAT-valued const (see `moduleConstFloatValuedFramed`), + /// resolved through the SAME cycle-guarded frame so a float-const leaf that + /// references another const is judged consistently with `lookupFloatName`. + pub fn nameIsFloatTyped(self: ModuleConstCtx, name: []const u8) bool { + return moduleConstFloatValuedFramed(self.consts, self.table, name, self.frame); + } }; +/// True iff `ty` is a float type — one half of the float-valued-const test the +/// int folder's division arm relies on. Module consts only ever carry the builtin +/// `f32` / `f64`. +fn isFloatConstType(ty: TypeId) bool { + return ty == .f32 or ty == .f64; +} + +/// True iff `name` is a FLOAT-valued module const — judged by the const's VALUE, +/// not only its DECLARED type, so it catches both a typed float const +/// (`K : f64 : 4.0`, `F : f64 : 2.5`) AND an UNTYPED float-EXPRESSION const +/// (`ME :: 4.0 + 1.0`), whose pass-0 placeholder type is `s64` even though its +/// value is float. The int folder's division arm consults this to tell a FLOAT +/// division apart from an integer one even when both operands fold to integers +/// (`K / 3`, `ME / 3`). `frame` cycle-guards a const whose value references +/// another const; a name already on the chain has no compile-time value → not +/// float-valued (issue 0095 / F0.11-6). +fn moduleConstFloatValuedFramed(consts: *const std.StringHashMap(ModuleConstInfo), table: *const types.TypeTable, name: []const u8, parent: ?*const ModuleConstFrame) bool { + if (moduleConstFrameContains(parent, name)) return false; + const ci = consts.get(name) orelse return false; + if (isFloatConstType(ci.ty)) return true; + var frame = ModuleConstFrame{ .name = name, .parent = parent }; + return isFloatValuedExpr(ci.value, ModuleConstCtx{ .consts = consts, .table = table, .frame = &frame }); +} + /// A module const may serve as an integer COUNT only when its DECLARED type is /// numeric — an integer of any width or a float (an integral float folds to its /// int via `floatToIntExact`). `moduleConstIntFramed` consults this so a count @@ -173,6 +203,61 @@ pub fn moduleConstFloat(consts: *const std.StringHashMap(ModuleConstInfo), table return moduleConstFloatFramed(consts, table, name, null); } +/// True iff `name` is a FLOAT-valued module const — judged by VALUE, so it covers +/// a typed float const (`K : f64 : 4.0`), an untyped float-EXPRESSION const +/// (`ME :: 4.0 + 1.0`, whose placeholder type is `s64`), and a non-integral float +/// const (`F : f64 : 2.5`). SINGLE source for the stateful (`Lowering`) and +/// stateless (`type_bridge`) division-arm float checks, so they agree on which +/// const-leaf divisions are float (issue 0095 / F0.11-6). +pub fn moduleConstIsFloatTyped(consts: *const std.StringHashMap(ModuleConstInfo), table: *const types.TypeTable, name: []const u8) bool { + return moduleConstFloatValuedFramed(consts, table, name, null); +} + +/// True iff `node` is a FLOAT-valued compile-time expression — a float literal, +/// a float-typed const leaf (`F : f64 : 2.5`, `K : f64 : 4.0`), a builtin float +/// numeric-limit (`f64.max`), or arithmetic over any of those. THE predicate the +/// int folder's division arm consults: `/` with a float operand is FLOAT division +/// (`5.0 / 2.0` = 2.5), and folding it with integer truncating division would +/// silently accept a non-integral float at a count / typed binding (issue 0095 / +/// F0.11-6). `+ - *` agree between int and float arithmetic for the integral +/// operands the int folder ever sees (a non-integral operand folds to null first), +/// so ONLY `/` needs this guard. A leaf name resolves through `ctx.nameIsFloatTyped` +/// — the same ctx that supplies `lookupDimName`/`lookupFloatName` — so an INTEGRAL +/// float const (`K : f64 : 4.0`, which folds to 4 as a standalone count) is still +/// recognised as float-valued inside a division. +/// +/// Also the precise "is this a compile-time float-valued initializer" test the +/// typed-binding narrowing path (`Lowering.foldComptimeFloatInit`) uses alongside +/// `inferExprType`, so an untyped float-EXPRESSION const (`ME :: 4.0 + 1.0`, +/// placeholder type `s64`) flowing into an integer binding (`x : s64 = ME / 2`) +/// is judged float-valued even though `inferExprType` reads its placeholder type. +pub fn isFloatValuedExpr(node: *const Node, ctx: anytype) bool { + return switch (node.data) { + .float_literal => true, + .int_literal => false, + .identifier => |id| ctx.nameIsFloatTyped(id.name), + .type_expr => |te| ctx.nameIsFloatTyped(te.name), + .field_access => |fa| blk: { + // A backtick RAW receiver (`` `f64.epsilon ``) is an ordinary field + // READ on a value whose spelling shadows a builtin type, NOT the + // numeric-limit accessor — so it is not a float leaf (issues 0092 / + // 0093). Only a BARE type receiver folds to a float limit. + const obj_name: ?[]const u8 = switch (fa.object.data) { + .identifier => |id| if (id.is_raw) null else id.name, + .type_expr => |te| if (te.is_raw) null else te.name, + else => null, + }; + if (obj_name) |on| { + if (type_resolver.TypeResolver.floatLimitFor(on, fa.field) != null) break :blk true; + } + break :blk false; + }, + .unary_op => |u| isFloatValuedExpr(u.operand, ctx), + .binary_op => |b| isFloatValuedExpr(b.lhs, ctx) or isFloatValuedExpr(b.rhs, ctx), + else => false, + }; +} + /// Evaluate a constant integer expression to its value. THE single /// integer-expression folder for the compiler — array dimensions (`[N]T`, /// `[M + 1]T`), Vector lane counts (`Vector(N, f32)`), generic value-param @@ -184,6 +269,13 @@ pub fn moduleConstFloat(consts: *const std.StringHashMap(ModuleConstInfo), table /// forms (`[M + N - 1]`, `[(M + 1) * 2]`) fold (a grouping `(…)` carries no AST /// node; the parser returns the inner expression). /// +/// ONE exception keeps a float operation out of integer arithmetic: a `/` whose +/// lhs/rhs is float-valued (`5.0 / 2.0`, `K / 3` with `K : f64 : 4.0`) is FLOAT +/// division, NOT integer truncation, so this folder refuses it (`isFloatValuedExpr`) +/// and lets `evalConstFloatExpr` + the unified narrowing rule see the true value +/// (issue 0095 / F0.11-6). `+ - *` need no such guard — they agree between int and +/// float arithmetic for the integral operands this folder ever sees. +/// /// Leaves resolve through the ctx, so each call site shares the SAME folding /// logic while contributing its own bindings: /// - `ctx.lookupDimName(name)` — a name bound to a compile-time integer. The @@ -238,7 +330,17 @@ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 { .add => std.math.add(i64, l, r) catch null, .sub => std.math.sub(i64, l, r) catch null, .mul => std.math.mul(i64, l, r) catch null, - .div => std.math.divTrunc(i64, l, r) catch null, + // A division with a FLOAT operand is FLOAT division (`5.0 / 2.0` + // = 2.5, `K / 3` with `K : f64 : 4.0` = 1.333…), NOT integer + // truncating division — refuse to fold it here so the value + // surfaces through `evalConstFloatExpr` + the unified float→int + // rule (integral folds, non-integral errors) instead of silently + // truncating to an integer (issue 0095 / F0.11-6). A genuine + // integer `/` (both operands integer-valued) still truncates. + .div => if (isFloatValuedExpr(b.lhs, ctx) or isFloatValuedExpr(b.rhs, ctx)) + null + else + std.math.divTrunc(i64, l, r) catch null, .mod => if (r == 0) null else @rem(l, r), else => null, }; diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index b041fb6..0450ed8 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -91,6 +91,16 @@ const StatelessInner = struct { const consts = self.consts orelse return null; return program_index_mod.moduleConstFloat(consts, self.table, name); } + /// True iff `name` is a FLOAT-typed module const — the registration-time twin + /// of `Lowering.nameIsFloatTyped`, routed through the SAME + /// `program_index.moduleConstIsFloatTyped` so the int folder's division arm + /// classifies a const-leaf division identically on the alias-registration path + /// as on the direct form (issue 0095 / F0.11-6, the issue-0083 unify-or-diverge + /// rule extended to the division guard). + pub fn nameIsFloatTyped(self: StatelessInner, name: []const u8) bool { + const consts = self.consts orelse return false; + return program_index_mod.moduleConstIsFloatTyped(consts, self.table, name); + } }; /// Fold a registration-time array dimension to its `DimU32` outcome through the