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.
16 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 explicitxx/cast(T)ALWAYS truncates (the escape). Applied consistently across typed local / param-default / field-default, typed module CONST, and array dim — all reusing the singleprogram_index.floatToIntExact/evalConstIntExprfacility (no second integral check).Fix (
src/ir/lower.zig,src/ir/module.zig,src/ir/program_index.zig):
Builder.constFloatInforeads a compile-timeconst_floatback from its Ref (value + span).coerceToTypenow means IMPLICIT coercion: its.float_to_intarm folds an integral const-float toconstInt, else emits the narrowing diagnostic.coerceExplicitis the raw truncating path;xx(lowerXX) andcast(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 byemitStructInit).- Const path:
typedConstInitFitsaccepts an integral float (literal or aM + 2.0-style expression that folds viaevalComptimeInt);emitModuleConst/constExprValue/globalInitValuefold an integral float to its int and reject a non-integral one.Completion (F0.11 attempt 2) — the direct-
const_floatcoerce 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 toevalConstIntExpr, delegating every integer subtree back to it (no parallel integer logic), adding only the float literal / negate /+ - * /arms.Lowering.foldComptimeFloatInitroutes the typed LOCAL, struct FIELD default, and call ARGUMENT (incl. an expanded param default) throughevalConstFloatExpr+floatToIntExact: an integral comptime float folds, a non-integral one errors, a genuine runtime float /xxcast is left to the normal path. (Run pureevalConstFloatExprFIRST so a$pack[i]arg isn't spuriously type-resolved out of binding.)- One
Lowering.diagNonIntegralNarrownow 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 readscannot implicitly narrow non-integral float …instead of the staleinitializer 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:evalConstFloatExprdelegated only integer leaves toevalConstIntExprand had no float-const leaf arm. Closed by completing the evaluator:
program_index.moduleConstFloat— the f64 twin ofmoduleConstInt(sameisCountableConstTypegate, same cyclic-definition frame), recovering a numeric module const's value throughevalConstFloatExpr. A newlookupFloatNamectx method (onLoweringandModuleConstCtx) surfaces a NON-INTEGRAL float const leaf;evalConstFloatExprgained.identifier/.type_exprarms that call it. Integer / integral-float leaves keep resolving through the existingevalConstIntExprdelegation, 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.typedConstInitFitsnow judges integral-fold viaevalConstFloatExpr+floatToIntExact(the SAME facilityfoldComptimeFloatInituses) instead of the int-onlyevalComptimeInt, which folded leaf-by-leaf ini64and 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 neitherevalConstIntExprnorevalConstFloatExprfolds it — a localM : i64 : 2inM + 0.5and a localF : f64 : 2.5inF + 0.25both 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]withN : f64 : 4.0) yet rejected an INTEGRAL expression built from a non-integral float-const leaf ([F + 1.5]= 4.0, or[K]withK : i64 : F + 1.5) as "must be a compile-time integer constant" — because the dim fold used the int-onlyevalConstIntExpr, 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:evalConstIntExprfirst, then (only on failure)evalConstFloatExpr+floatToIntExact.foldDimU32(array dim / Vector lane / u32 value-param) and the non-u32value-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_floatvariant 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 throughmoduleConstFloat) is the float twin of itslookupDimName, 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.4examples/1132wording (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
evalConstFloatExprstill LAGGEDevalConstIntExpr: the int evaluator resolves a numeric-limit field-access leaf (f64.true_min,f64.max) viatype_resolver.integerLimitFor, but the float evaluator had no parallel arm, soy : i64 = f64.true_min + 0.5(= 0.5) truncated silently to 0 (the directf64.true_minalready errored via the IR-levelconstFloatInfopath, but the expression form escaped). Closed by bringing the two evaluators to PARITY:
evalConstFloatExprgains a.field_accessarm that resolves a builtin FLOAT numeric-limit accessor throughtype_resolver.TypeResolver.floatLimitFor(the SAME facilitylowerNumericLimituses) — the float twin of the int evaluator'sintegerLimitForarm. Integer limits /<pack>.lenare 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.modbut the float one did not, soy : i64 = 5.5 % 2.0(= 1.5) truncated silently to 1.evalConstFloatExprnow handles.modvia@rem(matchingevalConstIntExprand codegen'sfrem;6.0 % 4.0folds 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/casttruncate 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 inexamples/0162-types-typed-module-const-roundtrip.sx, and the aligned const diagnostic inexamples/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-aliasArrFE :: [F + 1.5]i64all folding to len 4; 1146 adds[F + 0.25]i64erroring;examples/1132now 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 leaff64.max/f64.true_min/f32.epsilon,f64.max - f64.max→ 0,f64.true_min + 0.5→ 0.5, and the%arm5.5 % 2.0→ 1.5 /% 0.0→ null) and "foldCountI64 / foldDimU32 fold an integral float count, reject a non-integral one" (the count fold + thenon_integral_float/below_mindistinction). Attempt 5 also extends 0168 (positive:f64.max - f64.max→ 0,6.0 % 4.0→ 2, integer-limiti8.max/[u8.max]unregressed,xxescapes for both new forms) and 1146 (negative:f64.true_min + 0.5and5.5 % 2.0error 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:
evalConstIntExpraccepts 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.0printed2at a typed local, field default, param default, typed const, and array dimension), because the typed sites evaluate throughevalConstFloatExpr(which delegates the whole node to the int folder) and the count sites throughfoldCountI64(which tries the int folder first). Closed at the single root:evalConstIntExpr's.divarm now REFUSES to fold a division whose lhs/rhs is float-valued (a newisFloatValuedExprpredicate, resolving a float-typed const leaf through each ctx'snameIsFloatTyped) — so the division surfaces throughevalConstFloatExpr(float/) + the unified rule: an integral quotient (6.0 / 2.0→ 3) folds, a non-integral one (5.0 / 2.0= 2.5, mixed5 / 2.0, float-constF / 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.0errors at all five sites), the integral-/positives added toexamples/0168(6.0 / 2.0local/field,12.0 / 4.0const,[6.0 / 2.0]dim,xx (5.0 / 2.0)→ 2), and unitprogram_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 siblingisFloatValuedExpralready guards this with anis_rawcheck, butevalConstFloatExpr/evalConstIntExprdid NOT — so once the read flowed into an integer binding, the float folder returned the BUILTINf64.epsilon(2.22e-16) and the rule wrongly errored ("narrow non-integral float '0.0000…0002220446049250313'"), and the integer folder turned`i8.maxas an array dimension into the builtin127(a fabricated 127-element array) instead of an ordinary runtime field read. Closed at the single root: both evaluators' field-access arms now mirrorisFloatValuedExpr'sis_rawguard — a raw receiver yieldsobj_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.0is observable), so it is genuinely runtime and must not be const-folded: it now behaves EXACTLY like a plainly-named field read —`f64.epsilonnarrowing intoi64truncates to its field value (11.5→11, identical tob.epsilon, NOT a non-integral error on the builtin limit), and`i8.maxas an array dimension is rejected as a non-constant count (identical tob.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 unitprogram_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.