# 0095 — typed local/decl silently truncates a float initializer to an integer annotation > **RESOLVED (F0.11).** Agra ruled the UNIFIED rule (Option B): an implicit > float→int in a typed binding behaves exactly like the array-dimension rule — > an **integral** float FOLDS to its integer (`4.0` → 4, `-2.0` → -2), a > **non-integral** float is a COMPILE ERROR (`1.5`, `4.5`), and an explicit > `xx` / `cast(T)` ALWAYS truncates (the escape). Applied consistently across > typed local / param-default / field-default, typed module CONST, and array > dim — all reusing the single `program_index.floatToIntExact` / > `evalConstIntExpr` facility (no second integral check). > > Fix (`src/ir/lower.zig`, `src/ir/module.zig`, `src/ir/program_index.zig`): > - `Builder.constFloatInfo` reads a compile-time `const_float` back from its > Ref (value + span). > - `coerceToType` now means IMPLICIT coercion: its `.float_to_int` arm folds an > integral const-float to `constInt`, else emits the narrowing diagnostic. > `coerceExplicit` is the raw truncating path; `xx` (`lowerXX`) and > `cast(T)` route through it so the escape still truncates. > - Field-default lowering (struct-literal pad, named-field default, > `buildDefaultValue`) now coerces the default to the field type at the IR > level (was silently bit-coerced by `emitStructInit`). > - Const path: `typedConstInitFits` accepts an integral float (literal or a > `M + 2.0`-style expression that folds via `evalComptimeInt`); `emitModuleConst` > / `constExprValue` / `globalInitValue` fold an integral float to its int and > reject a non-integral one. > > **Completion (F0.11 attempt 2)** — the direct-`const_float` coerce arm only > caught a float LITERAL; a non-integral const-folded float EXPRESSION > (`local/field/param : i64 = M + 0.5`) still truncated silently. Closed by: > - New `program_index.evalConstFloatExpr` — the f64 counterpart to > `evalConstIntExpr`, delegating every integer subtree back to it (no parallel > integer logic), adding only the float literal / negate / `+ - * /` arms. > - `Lowering.foldComptimeFloatInit` routes the typed LOCAL, struct FIELD > default, and call ARGUMENT (incl. an expanded param default) through > `evalConstFloatExpr` + `floatToIntExact`: an integral comptime float folds, > a non-integral one errors, a genuine runtime float / `xx` cast is left to the > normal path. (Run pure `evalConstFloatExpr` FIRST so a `$pack[i]` arg isn't > spuriously type-resolved out of binding.) > - One `Lowering.diagNonIntegralNarrow` now emits the narrowing wording at all > five sites (coerce arm, global init, const-expr value, the typed-binding > sites, and the typed-const path), so the typed-CONST non-integral diagnostic > reads `cannot implicitly narrow non-integral float …` instead of the stale > `initializer is a float literal / floating-point expression`. > > **Completion (F0.11 attempt 3)** — attempt 2 resolved INT-const-expr leaves > (`M + 0.5`, `M :: 2`), but a non-integral result via a FLOAT-const leaf > (`F : f64 : 2.5; y : i64 = F + 0.25` = 2.75) still truncated silently: > `evalConstFloatExpr` delegated only integer leaves to `evalConstIntExpr` and had > no float-const leaf arm. Closed by completing the evaluator: > - `program_index.moduleConstFloat` — the f64 twin of `moduleConstInt` (same > `isCountableConstType` gate, same cyclic-definition frame), recovering a > numeric module const's value through `evalConstFloatExpr`. A new > `lookupFloatName` ctx method (on `Lowering` and `ModuleConstCtx`) surfaces a > NON-INTEGRAL float const leaf; `evalConstFloatExpr` gained `.identifier` / > `.type_expr` arms that call it. Integer / integral-float leaves keep resolving > through the existing `evalConstIntExpr` delegation, so the unified rule now > applies to ANY compile-time-constant float expression — literal, int-const > leaf, float-const leaf, and combinations — at every binding site. > - `typedConstInitFits` now judges integral-fold via `evalConstFloatExpr` + > `floatToIntExact` (the SAME facility `foldComptimeFloatInit` uses) instead of > the int-only `evalComptimeInt`, which folded leaf-by-leaf in `i64` and so > rejected an integral SUM built from a non-integral float leaf > (`K : i64 : F + 1.5` = 4.0). Integral float-const-leaf consts now FOLD; > non-integral ones still error with the unified wording. > - Out of scope (consistent with the int evaluator): a LOCAL `::` const leaf is > resolved as a scope ref, not through the const tables, so neither > `evalConstIntExpr` nor `evalConstFloatExpr` folds it — a local `M : i64 : 2` > in `M + 0.5` and a local `F : f64 : 2.5` in `F + 0.25` both still truncate > identically. Float now matches int exactly at that boundary. > > **Completion (F0.11 attempt 4)** — attempts 1–3 unified the four binding sites > (local / field / param / const) for compile-time float exprs, but the ARRAY- > DIMENSION / count path still diverged: it folded a DIRECT integral float literal > (`[4.0]`, `[N]` with `N : f64 : 4.0`) yet rejected an INTEGRAL expression built > from a non-integral float-const leaf (`[F + 1.5]` = 4.0, or `[K]` with > `K : i64 : F + 1.5`) as "must be a compile-time integer constant" — because the > dim fold used the int-only `evalConstIntExpr`, never the float-aware path. Closed > by routing the count fold through the SAME facility the other four sites use: > - New `program_index.foldCountI64` — the single int-or-integral-float count fold: > `evalConstIntExpr` first, then (only on failure) `evalConstFloatExpr` + > `floatToIntExact`. `foldDimU32` (array dim / Vector lane / u32 value-param) and > the non-`u32` value-param gate both delegate to it, so no count site disagrees > on which floats fold (the issue-0083 unify-or-diverge rule extended to floats). > - A new `DimU32.non_integral_float` variant carries a non-integral float dim to a > distinct, accurate diagnostic ("array dimension must be an integer, but '2.75' > is a non-integral float") rather than the generic "must be a compile-time > integer constant" — the cast-escape advice the binding sites give does not apply > in a dimension position, so the dim wording omits it. `reportDimError`, the > Vector-lane resolver, and the top-level array-alias diagnostic all handle the > new variant, so the DIRECT (`a : [F + 0.25]i64`) and type-ALIAS > (`Arr :: [F + 0.25]i64`) forms emit the identical message. > - `type_bridge.StatelessInner.lookupFloatName` (routed through `moduleConstFloat`) > is the float twin of its `lookupDimName`, so the registration-time alias path > folds a float-const-leaf dimension to the SAME count as the stateful direct > path. This relaxes the F0.4 `examples/1132` wording (a non-integral float const > dim now reports the precise "non-integral float" message; it still errors). > > **Completion (F0.11 attempt 5)** — attempts 1–4 unified all five sites for > literal / int-const-expr / float-const-leaf forms, but `evalConstFloatExpr` still > LAGGED `evalConstIntExpr`: the int evaluator resolves a numeric-limit field-access > leaf (`f64.true_min`, `f64.max`) via `type_resolver.integerLimitFor`, but the > float evaluator had no parallel arm, so `y : i64 = f64.true_min + 0.5` (= 0.5) > truncated silently to 0 (the direct `f64.true_min` already errored via the IR-level > `constFloatInfo` path, but the *expression* form escaped). Closed by bringing the > two evaluators to PARITY: > - `evalConstFloatExpr` gains a `.field_access` arm that resolves a builtin FLOAT > numeric-limit accessor through `type_resolver.TypeResolver.floatLimitFor` (the > SAME facility `lowerNumericLimit` uses) — the float twin of the int evaluator's > `integerLimitFor` arm. Integer limits / `.len` are still resolved by the > int delegation, so only the float-limit case lands here. > - The audit also surfaced a missing `%` arm: the int evaluator folds `.mod` but > the float one did not, so `y : i64 = 5.5 % 2.0` (= 1.5) truncated silently to 1. > `evalConstFloatExpr` now handles `.mod` via `@rem` (matching `evalConstIntExpr` > and codegen's `frem`; `6.0 % 4.0` folds to 2 via the int delegation, `5.5 % 2.0` > = 1.5 is rejected). The two evaluators are now at full leaf/operator parity, so > no compile-time-const float shape escapes the rule at one site while folding at > another. (A comptime-fn returning float is a genuinely new form for BOTH and is > out of scope.) > > Regression tests: `examples/0168-types-integral-float-to-int.sx` (positive — > local/field/param/const fold, integral int-const-EXPRESSION (`M + 2.0`) AND > float-const-LEAF (`F + 1.5`, `F : f64 : 2.5`) fold at local/field/param/const, > `xx`/`cast` truncate incl. `xx (M + 0.5)` / `xx (F + 0.25)`), > `examples/1146-diagnostics-nonintegral-float-to-int.sx` (negative — > non-integral LITERAL, int-const-EXPRESSION (`M + 0.5`), AND float-const-LEAF > (`F + 0.25`) error at local/param/field), the integral-float const cases in > `examples/0162-types-typed-module-const-roundtrip.sx`, and the aligned const > diagnostic in `examples/1143-diagnostics-typed-module-const-mismatch.sx` > (G / BAD / BAD2 stay errors with the new wording). The array-dimension site is > pinned in the same two examples: 0168 adds `[F + 1.5]i64`, `[KF]i64` > (`KF : i64 : F + 1.5`), and a type-alias `ArrFE :: [F + 1.5]i64` all folding to > len 4; 1146 adds `[F + 0.25]i64` erroring; `examples/1132` now expects the > precise non-integral-float dim wording. Unit: > `program_index.test.zig` "evalConstFloatExpr folds comptime float expressions" > (covers the float-const leaf: `F` → 2.5, `F + 0.25` → 2.75, `F + 1.5` → 4.0; > attempt 5 adds the numeric-limit leaf `f64.max`/`f64.true_min`/`f32.epsilon`, > `f64.max - f64.max` → 0, `f64.true_min + 0.5` → 0.5, and the `%` arm `5.5 % 2.0` > → 1.5 / `% 0.0` → null) and "foldCountI64 / foldDimU32 fold an integral float > count, reject a non-integral one" (the count fold + the `non_integral_float` / > `below_min` distinction). Attempt 5 also extends 0168 (positive: `f64.max - > f64.max` → 0, `6.0 % 4.0` → 2, integer-limit `i8.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". > > **Completion (F0.11 attempt 7)** — one structural hole survived in the > field-access arm of the SHARED const evaluators: a backtick raw value-shadow > receiver (`` `f64 := FBox.{ epsilon = … } `` then `` `f64.epsilon ``) was > misclassified as the builtin numeric-limit accessor. The sibling > `isFloatValuedExpr` already guards this with an `is_raw` check, but > `evalConstFloatExpr` / `evalConstIntExpr` did NOT — so once the read flowed into > an integer binding, the float folder returned the BUILTIN `f64.epsilon` > (2.22e-16) and the rule wrongly errored ("narrow non-integral float > '0.0000…0002220446049250313'"), and the integer folder turned `` `i8.max `` as an > array dimension into the builtin `127` (a fabricated 127-element array) instead > of an ordinary runtime field read. Closed at the single root: both evaluators' > field-access arms now mirror `isFloatValuedExpr`'s `is_raw` guard — a raw > receiver yields `obj_name = null`, so it is never a numeric-limit/pack leaf and > falls through to the ordinary runtime field read. A raw value-shadow is a > mutable-local field (a subsequent `` `f64.epsilon = 4.0 `` is observable), so it > is genuinely runtime and must not be const-folded: it now behaves EXACTLY like a > plainly-named field read — `` `f64.epsilon `` narrowing into `i64` truncates to > its field value (`11.5` → `11`, identical to `b.epsilon`, NOT a non-integral > error on the builtin limit), and `` `i8.max `` as an array dimension is rejected > as a non-constant count (identical to `b.max`). The bare builtin path is > unchanged (`f64.epsilon`, `i8.max`, `[u8.max]` still fold). Regression: > `examples/0169-types-value-shadow-field-narrowing.sx` (positive — raw float-field > read narrows/truncates, mutation proves runtime, bare limit still folds), > `examples/1148-diagnostics-value-shadow-field-dim-not-const.sx` (negative — raw > int-field dim rejected as non-const), and unit `program_index.test.zig` "a > backtick raw-shadow receiver is a field read, not a numeric-limit fold (F0.11-7)". ## Symptom A typed LOCAL (and likely typed param/field) silently truncates a floating-point initializer to an integer annotation instead of rejecting or requiring an explicit cast. Observed: - `y : i64 = 1.5;` → y == 1 (float literal truncated, no diagnostic) - `y : i64 = 2 + 0.5;` → y == 2 (float-valued expr truncated, no diagnostic) Expected: a type-mismatch / narrowing diagnostic (consistent with typed MODULE CONSTS, which after F0.7 reject `N : i64 : 1.5` and `N : i64 : M + 0.5`). Today consts are strict but locals are lenient — an inconsistency. ## Reproduction ```sx #import "modules/std.sx"; main :: () { y : i64 = 1.5; print("{}\n", y); // prints 1 } ``` ## Investigation prompt Decide + implement the language rule for implicit float→int narrowing in a TYPED binding (local / param / field) initializer. Module consts already reject it (F0.7, registerTypedModuleConst + typedConstInitFits/constExprInitFits). Make typed-local/param/field assignment-coercion consistent: either reject a non-integral float→int initializer with a diagnostic (matching the const path) or require an explicit `xx`/cast. Suspected area: the assignment / typed-binding coercion path (coerceToType ladder, specs.md §"coercion") in src/ir/lower.zig. Verify `y : i64 = 1.5` errors (or requires a cast); confirm integral-float folding rules (specs.md: `4.0`→4 ok, `4.5` rejected) stay consistent. Then gate. ## Disposition Discovered during F0.7 (issue 0088) attempt-2 review. Agra ruled F0.7 fixes the inferExprType ROOT for binary-op promotion; this typed-LOCAL narrowing is a SEPARATE assignment-coercion concern -> its own scheduled step.