# 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 : s64 = 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 : s64 = 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 : s64 : 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 : s64 : 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 : s64 : 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]s64`) and type-ALIAS
>   (`Arr :: [F + 0.25]s64`) 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 : s64 = 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 / `<pack>.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 : s64 = 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]s64`, `[KF]s64`
> (`KF : s64 : F + 1.5`), and a type-alias `ArrFE :: [F + 1.5]s64` all folding to
> len 4; 1146 adds `[F + 0.25]s64` 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 `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".
>
> **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 `` `s8.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 `s64` truncates to
> its field value (`11.5` → `11`, identical to `b.epsilon`, NOT a non-integral
> error on the builtin limit), and `` `s8.max `` as an array dimension is rejected
> as a non-constant count (identical to `b.max`). The bare builtin path is
> unchanged (`f64.epsilon`, `s8.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 : s64 = 1.5;`        → y == 1   (float literal truncated, no diagnostic)
- `y : s64 = 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 : s64 : 1.5` and `N : s64 : M + 0.5`). Today consts are strict
but locals are lenient — an inconsistency.

## Reproduction
```sx
#import "modules/std.sx";
main :: () {
    y : s64 = 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 : s64 = 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.
