Files
sx/issues/0095-typed-local-float-int-narrowing.md
agra 4c12e1de38 fix(ir): unify float→int narrowing — integral folds, non-integral errors [F0.11]
Issue 0095: a typed local/param/field silently TRUNCATED a float initializer
to an integer annotation (`y : s64 = 1.5` → 1) with no diagnostic. Agra ruled
the UNIFIED rule (Option B): an implicit float→int in a typed binding behaves
like the array-dimension rule —

  - an INTEGRAL compile-time float FOLDS to its int (`4.0` → 4, `-2.0` → -2);
  - a NON-integral float is a COMPILE ERROR (`1.5`, `4.5`);
  - explicit `xx` / `cast(T)` ALWAYS truncates (the escape hatch).

Applied consistently to 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).

- `Builder.constFloatInfo` reads a compile-time `const_float` back from its
  Ref (value + span).
- `coerceToType` is now the IMPLICIT path: 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 folding via `evalComptimeInt`); `emitModuleConst`
  / `constExprValue` / `globalInitValue` fold an integral float to its int and
  reject a non-integral one — relaxing F0.7's blanket float rejection.

Tests: examples/0168 (positive: local/field/param/const fold, xx/cast
truncate), examples/1146 (negative: local/param/field error), integral-float
const cases added to examples/0162; non-integral const cases in 1143 stay
errors. specs.md + readme.md document the unified rule, cross-referencing the
array-dim rule. issues/0095 marked RESOLVED.
2026-06-05 15:34:33 +03:00

3.6 KiB

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):

  • 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.

Regression tests: examples/0168-types-integral-float-to-int.sx (positive — local/field/param/const fold, xx/cast truncate), examples/1146-diagnostics- nonintegral-float-to-int.sx (negative — local/param/field error), plus the integral-float const cases added to examples/0162-types-typed-module-const- roundtrip.sx. Non-integral const cases in examples/1143 stay errors.

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

#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.