diff --git a/examples/0159-types-float-numeric-limits.sx b/examples/0159-types-float-numeric-limits.sx new file mode 100644 index 0000000..f2753ab --- /dev/null +++ b/examples/0159-types-float-numeric-limits.sx @@ -0,0 +1,89 @@ +// Float numeric-limit accessors: `f32`/`f64` expose `.min` / `.max` (sibling of +// the integer `.min`/`.max`, NL.1) plus the float-only `.epsilon`, +// `.min_positive`, `.true_min`, `.inf`, and `.nan`. Each folds, at compile time, +// to a constant of the QUERIED float type via the same `lowerNumericLimit` +// intercept as the integer case (`builder.constFloat` + the `std.math` +// constants), driven by `TypeResolver.floatLimitFor`. +// +// The lexer has no exponent notation and the default float formatter is crude +// (issue 0090), so these limits can be pinned NEITHER by literal comparison NOR +// by printing. Every accessor is asserted instead by reinterpreting its bits +// through an untagged union and comparing against the exact IEEE-754 hex +// pattern — plus the defining-property checks that no other value could satisfy. +// +// Semantics (Agra-ruled, consistent with the integer accessors): +// .min = most-NEGATIVE finite (= -max), NOT C's DBL_MIN +// .max = largest finite +// .epsilon = ULP of 1.0 (next f after 1.0 minus 1.0), NOT C#'s denormal Epsilon +// .min_positive = smallest positive NORMAL (= C DBL_MIN / Rust MIN_POSITIVE) +// .true_min = smallest positive SUBNORMAL (next value above 0.0) +// .inf = +infinity +// .nan = a quiet NaN +// +// Regression (issue 0091): `f64.nan != f64.nan` is true — native float `!=` +// lowers UNORDERED, so a NaN compares unequal to everything including itself. +#import "modules/std.sx"; + +// `bits` mirrors each float's raw IEEE-754 storage. f64 needs 64 bits, f32 32. +// The f64 union uses a `u64` view so the all-ones-ish positive patterns read as +// their true magnitude; the negative `f64.min` pattern (0xFFEF…) overflows the +// i64 literal parser, so it is pinned by the `min == -max` property instead. +Uf64 :: union { f: f64; bits: u64; } +Uf32 :: union { f: f32; bits: u32; } + +main :: () -> s32 { + o : Uf64 = ---; + + // Read `.true_min` (a subnormal) FIRST and through the union only — never via + // arithmetic. Under flush-to-zero / denormals-are-zero CPU modes a subnormal + // can flush to 0.0 on the first arithmetic op, so the bit reinterpret is the + // only reliable channel for it. + o.f = f64.true_min; + print("f64.true_min {}\n", o.bits == 0x0000000000000001); // true + + o.f = f64.max; + print("f64.max {}\n", o.bits == 0x7FEFFFFFFFFFFFFF); // true + o.f = f64.epsilon; + print("f64.epsilon {}\n", o.bits == 0x3CB0000000000000); // true + o.f = f64.min_positive; + print("f64.min_positive {}\n", o.bits == 0x0010000000000000); // true + o.f = f64.inf; + print("f64.inf {}\n", o.bits == 0x7FF0000000000000); // true + + p : Uf32 = ---; + p.f = f32.true_min; + print("f32.true_min {}\n", p.bits == 0x00000001); // true + p.f = f32.max; + print("f32.max {}\n", p.bits == 0x7F7FFFFF); // true + p.f = f32.min; + print("f32.min {}\n", p.bits == 0xFF7FFFFF); // true + p.f = f32.epsilon; + print("f32.epsilon {}\n", p.bits == 0x34000000); // true + p.f = f32.min_positive; + print("f32.min_positive {}\n", p.bits == 0x00800000); // true + p.f = f32.inf; + print("f32.inf {}\n", p.bits == 0x7F800000); // true + + // Defining-property checks — true epsilon is the ULP of 1.0: adding it to 1.0 + // changes the value, adding half of it does not (round-to-nearest-even). + print("(1+eps)!=1 {}\n", (1.0 + f64.epsilon) != 1.0); // true + print("(1+eps/2)==1 {}\n", (1.0 + f64.epsilon/2.0) == 1.0); // true + print("inf>max {}\n", f64.inf > f64.max); // true + // f64.min = -max (the 0xFFEF… bit pattern overflows the i64 literal parser). + print("min==-max {}\n", f64.min == -f64.max); // true + print("true_min0 {}\n", f64.true_min > 0.0); // true + // Quiet NaN: unequal to everything, itself included (mantissa bits not pinned). + print("nan!=nan {}\n", f64.nan != f64.nan); // true + + // Result carries the QUERIED type: each binding is declared with the float + // type and round-trips, so a mistyped fold (boxed as Any / wrong width) would + // not type-check here. + e64 : f64 = f64.epsilon; + e32 : f32 = f32.epsilon; + q : Uf64 = ---; q.f = e64; + r : Uf32 = ---; r.f = e32; + print("typed eps bits {}\n", q.bits == 0x3CB0000000000000 and r.bits == 0x34000000); // true + + return 0; +} diff --git a/examples/0160-types-float-numeric-limits-errors.sx b/examples/0160-types-float-numeric-limits-errors.sx new file mode 100644 index 0000000..f008213 --- /dev/null +++ b/examples/0160-types-float-numeric-limits-errors.sx @@ -0,0 +1,27 @@ +// Cross-type rules for the numeric-limit accessors. `.min` / `.max` are valid on +// BOTH integer and float types, but `.epsilon` / `.min_positive` / `.true_min` / +// `.inf` / `.nan` are FLOAT-ONLY. Applying a float-only accessor to an INTEGER +// type, or ANY accessor to a non-numeric type, is a clean compile error — never +// a silent value, never the `.unresolved` sentinel reaching codegen. +// +// - float-only accessor on an integer (`s32.epsilon`, `u8.inf`, +// `s64.true_min`) → a dedicated "applies only to float types" diagnostic +// from the accessor intercept, located at the access; +// - any accessor on a non-numeric builtin (`bool.nan`, `string.max`) → the +// "numeric limits apply only to integer and float types" diagnostic; +// - a user struct (`MyStruct.epsilon`) → the type name is not a builtin, so the +// intercept stays out and the existing field-not-found path reports it. +// Each case is accurate and located at the access; the program exits non-zero. +#import "modules/std.sx"; + +MyStruct :: struct { a: s64; } + +main :: () -> s32 { + a := s32.epsilon; + b := u8.inf; + c := s64.true_min; + d := bool.nan; + e := string.max; + f := MyStruct.epsilon; + return 0; +} diff --git a/examples/expected/0159-types-float-numeric-limits.exit b/examples/expected/0159-types-float-numeric-limits.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0159-types-float-numeric-limits.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0159-types-float-numeric-limits.stderr b/examples/expected/0159-types-float-numeric-limits.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0159-types-float-numeric-limits.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0159-types-float-numeric-limits.stdout b/examples/expected/0159-types-float-numeric-limits.stdout new file mode 100644 index 0000000..f562d64 --- /dev/null +++ b/examples/expected/0159-types-float-numeric-limits.stdout @@ -0,0 +1,19 @@ +f64.true_min true +f64.max true +f64.epsilon true +f64.min_positive true +f64.inf true +f32.true_min true +f32.max true +f32.min true +f32.epsilon true +f32.min_positive true +f32.inf true +(1+eps)!=1 true +(1+eps/2)==1 true +inf>max true +min==-max true +true_min0 true +nan!=nan true +typed eps bits true diff --git a/examples/expected/0160-types-float-numeric-limits-errors.exit b/examples/expected/0160-types-float-numeric-limits-errors.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0160-types-float-numeric-limits-errors.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0160-types-float-numeric-limits-errors.stderr b/examples/expected/0160-types-float-numeric-limits-errors.stderr new file mode 100644 index 0000000..7f9b2df --- /dev/null +++ b/examples/expected/0160-types-float-numeric-limits-errors.stderr @@ -0,0 +1,35 @@ +error: type 's32' has no '.epsilon' — '.epsilon' applies only to float types (f32/f64); integer types expose only '.min'/'.max' + --> examples/0160-types-float-numeric-limits-errors.sx:20:10 + | +20 | a := s32.epsilon; + | ^^^^^^^^^^^ + +error: type 'u8' has no '.inf' — '.inf' applies only to float types (f32/f64); integer types expose only '.min'/'.max' + --> examples/0160-types-float-numeric-limits-errors.sx:21:10 + | +21 | b := u8.inf; + | ^^^^^^ + +error: type 's64' has no '.true_min' — '.true_min' applies only to float types (f32/f64); integer types expose only '.min'/'.max' + --> examples/0160-types-float-numeric-limits-errors.sx:22:10 + | +22 | c := s64.true_min; + | ^^^^^^^^^^^^ + +error: type 'bool' has no '.nan' — numeric limits apply only to integer and float types + --> examples/0160-types-float-numeric-limits-errors.sx:23:10 + | +23 | d := bool.nan; + | ^^^^^^^^ + +error: type 'string' has no '.max' — numeric limits apply only to integer and float types + --> examples/0160-types-float-numeric-limits-errors.sx:24:10 + | +24 | e := string.max; + | ^^^^^^^^^^ + +error: field 'epsilon' not found on type 'Any' + --> examples/0160-types-float-numeric-limits-errors.sx:25:10 + | +25 | f := MyStruct.epsilon; + | ^^^^^^^^^^^^^^^^ diff --git a/examples/expected/0160-types-float-numeric-limits-errors.stdout b/examples/expected/0160-types-float-numeric-limits-errors.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0160-types-float-numeric-limits-errors.stdout @@ -0,0 +1 @@ + diff --git a/readme.md b/readme.md index 0c71b8e..a6b70d9 100644 --- a/readme.md +++ b/readme.md @@ -90,7 +90,13 @@ Options: a compile-time constant of that type: `s64.max` → `9223372036854775807`, `u8.min` → `0`, `s3.max` → `3`. It works for every width `s1`..`s64` / `u1`..`u64` plus `usize`/`isize`, and is usable anywhere a constant of that type is — including -array dimensions (`[u8.max]T` is a 255-element array). +array dimensions (`[u8.max]T` is a 255-element array). The float types `f32`/`f64` +expose `.min` / `.max` too (with `.min` = most-negative finite = `-max`, **not** +C's `DBL_MIN`) plus the float-only `.epsilon` (ULP of 1.0, not C#'s denormal +`Epsilon`), `.min_positive` (smallest normal = C `DBL_MIN`), `.true_min` (smallest +subnormal — beware flush-to-zero CPU modes), `.inf`, and `.nan`. A float-only +accessor on an integer (`s32.epsilon`), or any accessor on a non-numeric type, is +a clean compile error. See `specs.md` → Numeric Limits. ### Declarations diff --git a/specs.md b/specs.md index b30853e..506c6ea 100644 --- a/specs.md +++ b/specs.md @@ -283,6 +283,64 @@ n := u64.max; // 18446744073709551615 (all-ones) `string`, a pointer, a `struct`, `void`, an `enum`) is a compile error, never a silent value. +The **float** types `f32` and `f64` expose the same `.min` / `.max` plus a set of +float-only accessors. Each folds, at compile time, to a constant of the queried +float type (the same `lowerNumericLimit` intercept, via `builder.constFloat`): + +```sx +hi := f64.max; // largest finite double +lo := f64.min; // most-NEGATIVE finite = -max (NOT C's DBL_MIN) +eps := f64.epsilon; // ULP of 1.0 (f64 = 2^-52, f32 = 2^-23) +mp := f64.min_positive; // smallest positive NORMAL (= C DBL_MIN / Rust MIN_POSITIVE) +tm := f64.true_min; // smallest positive SUBNORMAL (next value above 0.0) +pin := f64.inf; // +infinity +qn := f64.nan; // a quiet NaN +``` + +- **Receiver.** `f32` or `f64`. +- **Shared with integers.** `.min` / `.max` are valid on BOTH integer and float + types. `.min` is the most-NEGATIVE finite value, i.e. `-max` — consistent with + the integer `.min`, and deliberately **NOT** C's `DBL_MIN`/`FLT_MIN` (which is + the smallest positive normal; that is `.min_positive` here). +- **Float-only accessors.** + - `.epsilon` — the ULP of `1.0`: the gap between `1.0` and the next + representable value (`f64 = 2^-52 ≈ 2.22e-16`, `f32 = 2^-23`). This is the + **machine epsilon** used for relative-tolerance comparisons, **NOT** C#'s + `Double.Epsilon` (which is the smallest denormal — that is `.true_min` here). + Defining property: `1.0 + epsilon != 1.0` while `1.0 + epsilon/2.0 == 1.0`. + - `.min_positive` — the smallest positive **NORMAL** value (`f64 = 2^-1022`, + `f32 = 2^-126`). Equals C's `DBL_MIN` / Rust's `MIN_POSITIVE`. + - `.true_min` — the smallest positive **SUBNORMAL**: the next value above `0.0` + (`f64` bits `0x0000000000000001 = 2^-1074`, `f32` bits `0x00000001 = 2^-149`). + Named `true_min` (after Zig's `floatTrueMin`) to avoid the Java/Go/JS + `MIN_VALUE` footgun, where a bare `MIN_VALUE` names the smallest *subnormal* + yet reads like the most-negative value. + - **FTZ/DAZ caveat.** Subnormals are exactly the values that vanish under + flush-to-zero (FTZ) / denormals-are-zero (DAZ) CPU modes. If such a mode is + active, a loaded `.true_min` can flush to `0.0` on the **first arithmetic + operation** that touches it. The folded constant always carries the exact + subnormal bit pattern; read or store it through a bit reinterpret *before* + any arithmetic if you need the true value to survive. Numerical-library + authors who toggle FTZ/DAZ should not be surprised when `true_min * 1.0` + reads back as `0.0`. + - `.inf` — positive infinity (`inf > max`). + - `.nan` — a quiet NaN. The exact mantissa bits are not pinned; the only + guaranteed property is that it is unequal to everything, itself included + (`nan != nan` is `true` — native float `!=` lowers unordered, issue 0091). +- **Float-only on an integer is an error.** `.epsilon` / `.min_positive` / + `.true_min` / `.inf` / `.nan` applied to an integer type (`s32.epsilon`, + `u8.inf`, `s64.true_min`) is a clean compile error — integer types expose only + `.min` / `.max`. +- **Pinning the values.** The lexer has no exponent notation and the default + float formatter is crude (issue 0090), so float limits can be asserted neither + by literal comparison nor by printing. Reinterpret the bits through an untagged + union (`union { f: f64; bits: u64 }`) and compare against the exact IEEE-754 + pattern — `f64.max = 0x7FEFFFFFFFFFFFFF`, `min = 0xFFEFFFFFFFFFFFFF`, + `epsilon = 0x3CB0000000000000`, `min_positive = 0x0010000000000000`, + `true_min = 0x0000000000000001`, `inf = 0x7FF0000000000000`; the `f32` set is + `0x7F7FFFFF` / `0xFF7FFFFF` / `0x34000000` / `0x00800000` / `0x00000001` / + `0x7F800000`. + ### 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/type_resolver.test.zig b/src/ir/type_resolver.test.zig index 70e895d..b81945f 100644 --- a/src/ir/type_resolver.test.zig +++ b/src/ir/type_resolver.test.zig @@ -246,3 +246,74 @@ test "TypeResolver.integerLimitFor: null for non-integer receivers and non-limit try std.testing.expect(TypeResolver.integerLimitFor("s64", "len") == null); try std.testing.expect(TypeResolver.integerLimitFor("u8", "epsilon") == null); } + +test "TypeResolver.isLimitField: the accessor set, nothing else" { + // The full numeric-limit surface — int .min/.max plus the float-only ones. + for ([_][]const u8{ "min", "max", "epsilon", "min_positive", "true_min", "inf", "nan" }) |f| { + try std.testing.expect(TypeResolver.isLimitField(f)); + } + // Ordinary fields / near-misses are not limit accessors. + try std.testing.expect(!TypeResolver.isLimitField("len")); + try std.testing.expect(!TypeResolver.isLimitField("ptr")); + try std.testing.expect(!TypeResolver.isLimitField("maximum")); + try std.testing.expect(!TypeResolver.isLimitField("Min")); + try std.testing.expect(!TypeResolver.isLimitField("")); +} + +test "TypeResolver.floatLimitFor: pinned f64 bit patterns" { + const L = struct { + fn bits(name: []const u8, field: []const u8) u64 { + return @bitCast(TypeResolver.floatLimitFor(name, field).?); + } + }; + // Exact IEEE-754 double bit patterns (the same values the example pins via + // a runtime bit reinterpret). + try std.testing.expectEqual(@as(u64, 0x7FEFFFFFFFFFFFFF), L.bits("f64", "max")); + try std.testing.expectEqual(@as(u64, 0xFFEFFFFFFFFFFFFF), L.bits("f64", "min")); // -max + try std.testing.expectEqual(@as(u64, 0x3CB0000000000000), L.bits("f64", "epsilon")); + try std.testing.expectEqual(@as(u64, 0x0010000000000000), L.bits("f64", "min_positive")); + try std.testing.expectEqual(@as(u64, 0x0000000000000001), L.bits("f64", "true_min")); + try std.testing.expectEqual(@as(u64, 0x7FF0000000000000), L.bits("f64", "inf")); + // .min = -max (NOT C's DBL_MIN, which is min_positive); the ordering holds. + const v = struct { + fn f(name: []const u8, field: []const u8) f64 { + return TypeResolver.floatLimitFor(name, field).?; + } + }; + try std.testing.expectEqual(v.f("f64", "min"), -v.f("f64", "max")); + try std.testing.expect(v.f("f64", "true_min") < v.f("f64", "min_positive")); + try std.testing.expect(v.f("f64", "true_min") > 0.0); + try std.testing.expect(std.math.isInf(v.f("f64", "inf"))); + // Quiet NaN: unequal to itself; exact mantissa bits intentionally not pinned. + try std.testing.expect(std.math.isNan(v.f("f64", "nan"))); +} + +test "TypeResolver.floatLimitFor: pinned f32 bit patterns (widened value narrows losslessly)" { + const L = struct { + // floatLimitFor widens every f32 limit to f64; narrowing back is lossless + // (the codegen path does the same via constFloat at the queried width). + fn bits(name: []const u8, field: []const u8) u32 { + return @bitCast(@as(f32, @floatCast(TypeResolver.floatLimitFor(name, field).?))); + } + }; + try std.testing.expectEqual(@as(u32, 0x7F7FFFFF), L.bits("f32", "max")); + try std.testing.expectEqual(@as(u32, 0xFF7FFFFF), L.bits("f32", "min")); // -max + try std.testing.expectEqual(@as(u32, 0x34000000), L.bits("f32", "epsilon")); + try std.testing.expectEqual(@as(u32, 0x00800000), L.bits("f32", "min_positive")); + try std.testing.expectEqual(@as(u32, 0x00000001), L.bits("f32", "true_min")); + try std.testing.expectEqual(@as(u32, 0x7F800000), L.bits("f32", "inf")); + const nan_v: f32 = @floatCast(TypeResolver.floatLimitFor("f32", "nan").?); + try std.testing.expect(std.math.isNan(nan_v)); +} + +test "TypeResolver.floatLimitFor: null for non-float receivers and non-limit fields" { + // Integer / non-numeric / user names are not float-limit folds. + try std.testing.expect(TypeResolver.floatLimitFor("s32", "epsilon") == null); + try std.testing.expect(TypeResolver.floatLimitFor("u64", "max") == null); + try std.testing.expect(TypeResolver.floatLimitFor("usize", "min") == null); + try std.testing.expect(TypeResolver.floatLimitFor("bool", "nan") == null); + try std.testing.expect(TypeResolver.floatLimitFor("MyStruct", "inf") == null); + // A builtin float with a non-limit field is not a fold here. + try std.testing.expect(TypeResolver.floatLimitFor("f64", "len") == null); + try std.testing.expect(TypeResolver.floatLimitFor("f32", "ptr") == null); +}