Files
sx/issues/0095-typed-local-float-int-narrowing.md
agra d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +03:00

16 KiB
Raw Permalink Blame History

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 13 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 14 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 / <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 : 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.511, 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

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