fix(ir): complete const-float evaluator — resolve float-const leaves too [F0.11]

Completes issue 0095: a non-integral float→int narrowing via a FLOAT-const
leaf (`F : f64 : 2.5; y : s64 = F + 0.25` = 2.75) silently truncated to 2.
`evalConstFloatExpr` delegated only INTEGER leaves to `evalConstIntExpr` and
had no float-const leaf arm, so the unified rule never saw the value.

- program_index.zig: add `moduleConstFloat`/`moduleConstFloatFramed` — the f64
  twin of `moduleConstInt` (same `isCountableConstType` gate, same cyclic-
  definition frame), recovering a numeric module const's value via
  `evalConstFloatExpr`. Add `lookupFloatName` to `ModuleConstCtx` and the
  `.identifier`/`.type_expr` leaf arms to `evalConstFloatExpr` 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.
- lower.zig: add `Lowering.lookupFloatName` delegating to `moduleConstFloat`.
  Route `typedConstInitFits`' integral-fold check through `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 now folds; `K : s64 : F + 0.25` errors).

A LOCAL `::` const leaf is a scope ref (not in the const tables) so neither
the int nor float evaluator folds it — float now matches int exactly there.

Regression: examples/1146 (negative) + 0168 (positive) extended with
float-const-leaf cases at local/field/param/const; unit test in
program_index.test.zig covers the leaf resolution (F→2.5, F+0.25→2.75,
F+1.5→4.0). specs.md + readme.md state the rule covers any compile-time-const
float expression incl. float-typed const leaves. issues/0095 banner updated.

Gate: zig build + zig build test green; 447 examples pass, 0 failed.
This commit is contained in:
agra
2026-06-05 17:00:12 +03:00
parent 43d44fff75
commit b6d66d9c56
10 changed files with 229 additions and 76 deletions

View File

@@ -988,15 +988,22 @@ pub const Lowering = struct {
/// `B : s64 : true`.
fn typedConstInitFits(self: *Lowering, value: *const Node, dst_ty: TypeId) bool {
// An INTEGER-annotated constant accepts a compile-time INTEGRAL float —
// a literal (`K : s64 : 4.0`) or an expression that folds to an integer
// (`K : s64 : M + 2.0` → 4) — via the SAME `evalConstIntExpr` /
// `floatToIntExact` the array-dim path uses. A non-integral float
// (`1.5`, `M + 0.5`) folds to null and falls through to the rejecting
// checks below, matching the typed-local rule.
// a literal (`K : s64 : 4.0`), an int-leaf expression (`K : s64 : M + 2.0`
// → 4), or a float-const-leaf expression whose SUM is integral
// (`F : f64 : 2.5; K : s64 : F + 1.5` → 4). Integrality is judged on the
// FLOAT fold (`evalConstFloatExpr` + `floatToIntExact`) — the SAME facility
// the typed-local path (`foldComptimeFloatInit`) uses — not the int-only
// folder, which folds leaf-by-leaf in `i64` and so misses an integral SUM
// built from a non-integral float leaf. A non-integral fold (`1.5`,
// `M + 0.5`, `F + 0.25`) yields null here and falls through to the
// rejecting checks below, where `registerTypedModuleConst` emits the
// unified narrowing diagnostic.
if (self.isIntEx(dst_ty)) {
switch (value.data) {
.float_literal, .binary_op, .unary_op => {
if (self.evalComptimeInt(value) != null) return true;
if (program_index_mod.evalConstFloatExpr(value, self)) |fv| {
if (program_index_mod.floatToIntExact(fv) != null) return true;
}
},
else => {},
}
@@ -12113,6 +12120,18 @@ pub const Lowering = struct {
return null;
}
/// Float-valued leaf for the shared float-expression evaluator: a name bound
/// to a NUMERIC module const whose compile-time value is a (non-integral)
/// float — the FLOAT counterpart of `lookupDimName`, routed through the SAME
/// `module_const_map` so the unified narrowing rule resolves a float-const
/// leaf (`F : f64 : 2.5`) exactly as it resolves an int-const leaf. Integer /
/// integral-float leaves and comptime int bindings are already resolved by the
/// `evalConstIntExpr` delegation inside `evalConstFloatExpr`; this surfaces the
/// non-integral float const so the rule can reject it.
pub fn lookupFloatName(self: *Lowering, name: []const u8) ?f64 {
return program_index_mod.moduleConstFloat(&self.program_index.module_const_map, &self.module.types, name);
}
/// Resolve a name to a compile-time integer across the three const tables.
fn comptimeIntNamed(self: *Lowering, name: []const u8) ?i64 {
if (self.comptime_constants.get(name)) |cv| switch (cv) {

View File

@@ -111,6 +111,14 @@ const DimCtx = struct {
if (std.mem.eql(u8, name, "xs")) return 3;
return null;
}
// `F` stands in for a NON-INTEGRAL float module const (`F : f64 : 2.5`): the
// int folder cannot resolve it, so only the float-leaf lookup surfaces it.
// Integer consts (`M`/`N`) are resolved by the int delegation and never reach
// this arm; `Z` is genuinely runtime.
pub fn lookupFloatName(_: DimCtx, name: []const u8) ?f64 {
if (std.mem.eql(u8, name, "F")) return 2.5;
return null;
}
};
fn nLit(v: i64) ast.Node {
@@ -345,6 +353,21 @@ test "evalConstFloatExpr folds comptime float expressions, halts on runtime leav
var neg = nNeg(&mp);
try std.testing.expectEqual(@as(?f64, -4.5), eval(&neg, ctx));
// A NON-INTEGRAL float-const leaf (`F : f64 : 2.5`) resolves through the
// float-leaf lookup — the int folder cannot fold it (2.5 is not integral), so
// an expression like `F + 0.25` (= 2.75) is now recognised as a compile-time
// float and rejected by the narrowing rule instead of silently truncating;
// `F + 1.5` (= 4.0) is integral and folds. This completes the evaluator for
// float-const-leaf expressions (issue 0095, attempt 3).
var f = nIdent("F");
var quarter = nFloat(0.25);
var three_half = nFloat(1.5);
var fq = nBin(.add, &f, &quarter);
var fh = nBin(.add, &f, &three_half);
try std.testing.expectEqual(@as(?f64, 2.5), eval(&f, ctx));
try std.testing.expectEqual(@as(?f64, 2.75), eval(&fq, ctx));
try std.testing.expectEqual(@as(?f64, 4.0), eval(&fh, ctx));
// A runtime operand poisons the whole fold; a non-arithmetic operator and a
// float division by zero are not compile-time float leaves → null.
var zp = nBin(.add, &z, &half);

View File

@@ -99,6 +99,13 @@ const ModuleConstCtx = struct {
pub fn lookupPackLen(_: ModuleConstCtx, _: []const u8) ?i64 {
return null;
}
/// Float counterpart of `lookupDimName`, so `evalConstFloatExpr` resolves a
/// float-const leaf whose value references another const
/// (`G : f64 : 2.0; F : f64 : G + 0.5`) recursively through the SAME
/// cycle-guarded frame.
pub fn lookupFloatName(self: ModuleConstCtx, name: []const u8) ?f64 {
return moduleConstFloatFramed(self.consts, self.table, name, self.frame);
}
};
/// A module const may serve as an integer COUNT only when its DECLARED type is
@@ -144,6 +151,28 @@ pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), table:
return moduleConstIntFramed(consts, table, name, null);
}
/// FLOAT counterpart of `moduleConstInt`: a name bound to a NUMERIC module const
/// → its compile-time `f64` value (`F : f64 : 2.5` → 2.5), else null. Mirrors
/// `moduleConstIntFramed` exactly — same `isCountableConstType` gate, same cyclic-
/// definition frame — but recovers the value through `evalConstFloatExpr`, so the
/// unified float→int narrowing rule resolves a NON-INTEGRAL float-const leaf
/// (`y : s64 = F + 0.25`) the same way the int folder resolves an int-const leaf
/// (`M :: 2; y : s64 = M + 0.5`). An integral float / integer const folds through
/// the int path inside `evalConstFloatExpr` and never reaches the leaf arm that
/// calls this; this surfaces the genuinely non-integral float so `floatToIntExact`
/// can reject it.
fn moduleConstFloatFramed(consts: *const std.StringHashMap(ModuleConstInfo), table: *const types.TypeTable, name: []const u8, parent: ?*const ModuleConstFrame) ?f64 {
if (moduleConstFrameContains(parent, name)) return null;
const ci = consts.get(name) orelse return null;
if (!isCountableConstType(table, ci.ty)) return null;
var frame = ModuleConstFrame{ .name = name, .parent = parent };
return evalConstFloatExpr(ci.value, ModuleConstCtx{ .consts = consts, .table = table, .frame = &frame });
}
pub fn moduleConstFloat(consts: *const std.StringHashMap(ModuleConstInfo), table: *const types.TypeTable, name: []const u8) ?f64 {
return moduleConstFloatFramed(consts, table, name, null);
}
/// Evaluate a constant integer expression to its value. THE single
/// integer-expression folder for the compiler — array dimensions (`[N]T`,
/// `[M + 1]T`), Vector lane counts (`Vector(N, f32)`), generic value-param
@@ -228,9 +257,18 @@ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 {
/// An all-integer-foldable subtree is delegated to `evalConstIntExpr` (so module
/// / comptime consts, `<IntType>.min`/`.max`, and integer arithmetic resolve
/// through the SINGLE int folder — no parallel integer logic here); only the
/// genuinely float-producing shapes — a float literal, a unary negate, and
/// `+ - * /` arithmetic involving a float — are evaluated here in `f64`. A `%`,
/// comparison, or any other shape is not a compile-time float leaf → null.
/// genuinely float-producing shapes — a float literal, a NON-INTEGRAL float-const
/// leaf, a unary negate, and `+ - * /` arithmetic involving a float — are
/// evaluated here in `f64`. A `%`, comparison, or any other shape is not a
/// compile-time float leaf → null.
///
/// A NAMED-const leaf resolves through `ctx.lookupFloatName`, the float twin of
/// the `lookupDimName` the int folder uses: a numeric module const whose value is
/// a non-integral float (`F : f64 : 2.5`) surfaces here so `F + 0.25` (= 2.75) is
/// recognised as a compile-time float and rejected by the narrowing rule, exactly
/// as `M + 0.5` (with `M :: 2`) already is. An INTEGRAL float / integer const
/// (`K : f64 : 4.0`, `M :: 2`) is resolved by the `evalConstIntExpr` delegation
/// above and never reaches the leaf arm.
pub fn evalConstFloatExpr(node: *const Node, ctx: anytype) ?f64 {
// Delegate any integer-foldable subtree (incl. an INTEGRAL float like `4.0`
// / `M + 2.0`) to the single int folder, then promote — keeps named consts
@@ -238,6 +276,10 @@ pub fn evalConstFloatExpr(node: *const Node, ctx: anytype) ?f64 {
if (evalConstIntExpr(node, ctx)) |iv| return @floatFromInt(iv);
return switch (node.data) {
.float_literal => |lit| lit.value,
// A name bound to a numeric module const whose value is a non-integral
// float (the integral / integer cases were caught by the int delegation).
.identifier => |id| ctx.lookupFloatName(id.name),
.type_expr => |te| ctx.lookupFloatName(te.name),
.unary_op => |u| switch (u.op) {
.negate => {
const v = evalConstFloatExpr(u.operand, ctx) orelse return null;