diff --git a/examples/0169-types-value-shadow-field-narrowing.sx b/examples/0169-types-value-shadow-field-narrowing.sx new file mode 100644 index 0000000..c2c3766 --- /dev/null +++ b/examples/0169-types-value-shadow-field-narrowing.sx @@ -0,0 +1,58 @@ +// A raw value binding whose spelling shadows a builtin FLOAT type name +// (`` `f64 ``) and whose FLOAT field is read into an INTEGER binding. Field +// access on such a value is an ORDINARY runtime field read — the unified +// float→int narrowing rule (F0.11) must treat it EXACTLY like a non-shadowed +// struct's field read, never as the builtin numeric-limit accessor. So +// `` `f64.epsilon `` reads the value's `epsilon` field (a runtime f64) and a +// float→int narrowing TRUNCATES it, identical to a plainly-named `b.epsilon` — +// it does NOT fold the builtin `f64.epsilon` (= 2.22e-16) into the binding. +// +// The receiver is a mutable `:=` local, so its field is a RUNTIME value, not a +// compile-time constant: reading it after a reassignment yields the new value, +// proving it can never be const-folded from the initializer literal. +// +// Companion to 0161 (value-shadow field reads in NON-narrowing, s64-field +// contexts). This file exercises the narrowing path 0161 does not: a FLOAT +// field flowing into an integer binding. +// +// Regression (issue 0095 / F0.11-7): the compile-time float evaluator's +// field-access arm misclassified a raw value-shadow receiver as the builtin +// numeric-limit accessor, so `` `f64.epsilon `` newly errored under the +// narrowing rule with the BUILTIN value (2.22e-16) instead of reading the +// field. The fix mirrors the `is_raw` guard the sibling `isFloatValuedExpr` +// already applies, so the const-folding cluster agrees: a raw receiver is a +// field read, only a bare type receiver folds a limit. +#import "modules/std.sx"; + +FBox :: struct { epsilon: f64; } + +main :: () { + // Raw value-shadow of the builtin `f64`, FLOAT field → narrow into s64. + // Ordinary field read + runtime float→int truncation: 11.0 → 11. + `f64 := FBox.{ epsilon = 11.0 }; + x : s64 = `f64.epsilon; + + // A NON-integral field value truncates exactly the same way — a runtime + // f64 has no compile-time value to fold, so 11.5 → 11 (NOT a non-integral + // narrowing error, which would only fire on a compile-time-constant float). + `f64b := FBox.{ epsilon = 11.5 }; + y : s64 = `f64b.epsilon; + + // The value-shadowed read is identical to a plainly-named one: `b.epsilon` + // narrows the same way, so the backtick spelling changes nothing. + b := FBox.{ epsilon = 11.5 }; + yb : s64 = b.epsilon; + + print("x={} y={} yb={}\n", x, y, yb); // 11 11 11 + + // The field is a RUNTIME value: reassign, then read → the new value, not + // the initializer literal (so const-folding it would be unsound). + `f64.epsilon = 4.0; + xm : s64 = `f64.epsilon; + print("xm={}\n", xm); // 4 + + // The bare builtin receiver (not raw-escaped) is UNAFFECTED — it still + // folds the numeric limit. `f64.max - f64.max` = 0.0 is integral → 0. + lim : s64 = f64.max - f64.max; + print("lim={}\n", lim); // 0 +} diff --git a/examples/1148-diagnostics-value-shadow-field-dim-not-const.sx b/examples/1148-diagnostics-value-shadow-field-dim-not-const.sx new file mode 100644 index 0000000..d6554c3 --- /dev/null +++ b/examples/1148-diagnostics-value-shadow-field-dim-not-const.sx @@ -0,0 +1,29 @@ +// A raw value binding whose spelling shadows a builtin INTEGER type name +// (`` `s8 ``) used as an array DIMENSION through one of its fields. Field +// access on a raw value is an ORDINARY runtime field read, so `` `s8.max `` is +// a runtime value — NOT the builtin `s8.max` (= 127) and NOT a compile-time +// constant. An array dimension demands a compile-time integer constant, so the +// dimension is rejected with the same diagnostic a plainly-named runtime field +// read (`b.max`) earns — the backtick spelling changes nothing. +// +// Sibling (integer) half of the F0.11-7 fix: the compile-time INTEGER evaluator +// (`evalConstIntExpr`) misclassified a raw value-shadow receiver as the builtin +// `.min`/`.max` accessor, silently folding 127 and fabricating a +// 127-element array. The `is_raw` guard now defers it to an ordinary field +// read, so it surfaces as a non-constant dimension instead of a silent wrong +// length. +// +// Negative companion to 0169 (the FLOAT-field narrowing half, exit 0). +// +// Regression (issue 0095 / F0.11-7). +#import "modules/std.sx"; + +DimBox :: struct { max: s64; } + +main :: () { + `s8 := DimBox.{ max = 3 }; + // Raw value-shadow field read → a runtime value, not the builtin `s8.max` + // (127) and not a compile-time constant → rejected as a non-const dim. + arr : [`s8.max]f32 = ---; + print("len={}\n", arr.len); +} diff --git a/examples/expected/0169-types-value-shadow-field-narrowing.exit b/examples/expected/0169-types-value-shadow-field-narrowing.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0169-types-value-shadow-field-narrowing.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0169-types-value-shadow-field-narrowing.stderr b/examples/expected/0169-types-value-shadow-field-narrowing.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0169-types-value-shadow-field-narrowing.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0169-types-value-shadow-field-narrowing.stdout b/examples/expected/0169-types-value-shadow-field-narrowing.stdout new file mode 100644 index 0000000..92e21fe --- /dev/null +++ b/examples/expected/0169-types-value-shadow-field-narrowing.stdout @@ -0,0 +1,3 @@ +x=11 y=11 yb=11 +xm=4 +lim=0 diff --git a/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.exit b/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.stderr b/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.stderr new file mode 100644 index 0000000..c3e549d --- /dev/null +++ b/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.stderr @@ -0,0 +1,5 @@ +error: array dimension must be a compile-time integer constant + --> examples/1148-diagnostics-value-shadow-field-dim-not-const.sx:27:13 + | +27 | arr : [`s8.max]f32 = ---; + | ^^^^^^ diff --git a/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.stdout b/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1148-diagnostics-value-shadow-field-dim-not-const.stdout @@ -0,0 +1 @@ + diff --git a/issues/0095-typed-local-float-int-narrowing.md b/issues/0095-typed-local-float-int-narrowing.md index c2945ba..0e820e3 100644 --- a/issues/0095-typed-local-float-int-narrowing.md +++ b/issues/0095-typed-local-float-int-narrowing.md @@ -165,6 +165,33 @@ > 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 diff --git a/readme.md b/readme.md index cb6591f..9e4a688 100644 --- a/readme.md +++ b/readme.md @@ -99,7 +99,10 @@ accessor on an integer (`s32.epsilon`), or any accessor on a non-numeric type, i a clean compile error. The fold applies only to a bare type-name receiver: a raw identifier that binds a value shadowing a type name (`` `f64 := … `` then `` `f64.epsilon ``) reads the value's field, not the limit — for a local, global, -or module-constant binding alike. See `specs.md` → Numeric Limits. +or module-constant binding alike. This stays an ordinary *runtime* field read +even when it flows into an integer binding or an array dimension, so it truncates +(its field value) / is a non-constant count — never the builtin limit. See +`specs.md` → Numeric Limits. ### Declarations diff --git a/specs.md b/specs.md index 1e28e80..3c3ded9 100644 --- a/specs.md +++ b/specs.md @@ -353,7 +353,12 @@ qn := f64.nan; // a quiet NaN global, or a `` `f64 :: … `` module constant — so the fold can never silently hijack a raw value, whatever its scope. The two never collide: a bare builtin name in expression position is always a type, and only the raw `` `…` `` spelling - can bind a value under it. + can bind a value under it. The same rule governs the compile-time **narrowing + and count** contexts: a raw value-shadow field read is an ordinary *runtime* + read there too — never a compile-time numeric-limit leaf — so `` `f64.epsilon `` + narrowing into an integer binding truncates like any runtime float (its field + value, not the limit), and `` `s8.max `` used as an array dimension is rejected + as a non-constant count rather than folding to the builtin `127`. ### Enum Types User-defined sum types with named variants. Variants may optionally carry typed data (tagged unions). Internally, payload-less enums are represented as `i64` (variant index). Enums with payloads are represented as `{ i64, [max_payload_size x i8] }` (tag + data). diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index b339996..edaa2eb 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -141,6 +141,12 @@ fn nFloat(v: f64) ast.Node { fn nIdent(name: []const u8) ast.Node { return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = name } } }; } +/// A backtick RAW identifier (`` `f64 ``): same spelling as a builtin type, but +/// bound as a value — so a field access on it is an ordinary field read, never a +/// numeric-limit fold (F0.11-7). +fn nIdentRaw(name: []const u8) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = name, .is_raw = true } } }; +} fn nBin(op: ast.BinaryOp.Op, l: *ast.Node, r: *ast.Node) ast.Node { return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .binary_op = .{ .op = op, .lhs = l, .rhs = r } } }; } @@ -469,6 +475,40 @@ test "evalConstFloatExpr folds comptime float expressions, halts on runtime leav try std.testing.expect(eval(&divz, ctx) == null); } +test "a backtick raw-shadow receiver is a field read, not a numeric-limit fold (F0.11-7)" { + const evalf = pi.evalConstFloatExpr; + const evali = pi.evalConstIntExpr; + const ctx = DimCtx{}; + + // BARE type receiver (`is_raw = false`) → the numeric-limit accessor folds: + // `f64.epsilon` is the builtin eps, `s8.max` is 127. + var f64ty = nIdent("f64"); + var s8ty = nIdent("s8"); + var bare_feps = nField(&f64ty, "epsilon"); + var bare_smax = nField(&s8ty, "max"); + try std.testing.expectEqual(@as(?f64, @as(f64, std.math.floatEps(f64))), evalf(&bare_feps, ctx)); + try std.testing.expectEqual(@as(?i64, std.math.maxInt(i8)), evali(&bare_smax, ctx)); + + // RAW receiver (`` `f64 ``/`` `s8 ``) shadows the builtin with a VALUE — the + // field access is an ordinary runtime field READ, so it is NOT a compile-time + // leaf in either evaluator (→ null), exactly as the sibling `isFloatValuedExpr` + // already treats it. The whole point: a value-shadow can never be misread as + // the builtin limit (issue 0095 / F0.11-7). + var f64raw = nIdentRaw("f64"); + var s8raw = nIdentRaw("s8"); + var raw_feps = nField(&f64raw, "epsilon"); + var raw_smax = nField(&s8raw, "max"); + try std.testing.expect(evalf(&raw_feps, ctx) == null); + try std.testing.expect(evali(&raw_smax, ctx) == null); + // The float evaluator must also refuse it (it delegates the int path first): + try std.testing.expect(evalf(&raw_smax, ctx) == null); + + // `isFloatValuedExpr` (the consistency anchor) agrees: bare float-limit is + // float-valued, raw shadow is not. + try std.testing.expect(pi.isFloatValuedExpr(&bare_feps, ctx)); + try std.testing.expect(!pi.isFloatValuedExpr(&raw_feps, ctx)); +} + test "foldCountI64 / foldDimU32 fold an integral float count, reject a non-integral one" { const ctx = DimCtx{}; // M = 4, F = 2.5 (non-integral float const) diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index c6c20d1..c296912 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -298,9 +298,17 @@ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 { .identifier => |id| ctx.lookupDimName(id.name), .type_expr => |te| ctx.lookupDimName(te.name), .field_access => |fa| blk: { + // A backtick RAW receiver (`` `s64.max ``, `` `f64.epsilon ``) is an + // ordinary field READ on a value whose spelling shadows a builtin + // type name, NOT a numeric-limit / pack-arity accessor — so it is + // never a compile-time leaf here; its field is a runtime value + // (issues 0092/0093, F0.11-7). Only a BARE type/name receiver folds a + // `.len` / `.min`/`.max`. Mirrors the same `is_raw` + // guard `isFloatValuedExpr` already applies, so the const cluster + // (this folder, `evalConstFloatExpr`, `isFloatValuedExpr`) agrees. const obj_name: ?[]const u8 = switch (fa.object.data) { - .identifier => |id| id.name, - .type_expr => |te| te.name, + .identifier => |id| if (id.is_raw) null else id.name, + .type_expr => |te| if (te.is_raw) null else te.name, else => null, }; if (obj_name) |on| { @@ -397,9 +405,15 @@ pub fn evalConstFloatExpr(node: *const Node, ctx: anytype) ?f64 { // uses) so the two evaluators can't disagree on what `f64.max` // evaluates to. Integer limits and `.len` are already resolved // by the int delegation above, so only the float-limit case remains. + // A backtick RAW receiver (`` `f64.epsilon ``) is an ordinary field + // READ on a value that shadows a builtin float type name, NOT the + // numeric-limit accessor — its field is a runtime value, never a + // compile-time leaf (issues 0092/0093, F0.11-7). Mirrors the `is_raw` + // guard `isFloatValuedExpr` already applies; only a BARE type receiver + // folds a float limit. const obj_name: ?[]const u8 = switch (fa.object.data) { - .identifier => |id| id.name, - .type_expr => |te| te.name, + .identifier => |id| if (id.is_raw) null else id.name, + .type_expr => |te| if (te.is_raw) null else te.name, else => null, }; if (obj_name) |on| {