feat(lang): float numeric-limit accessors — examples, unit tests, docs [NL.2]

Finish NL.2 on top of the WIP compiler impl (2e9e4fe): f32/f64 expose
.min/.max plus the float-only .epsilon/.min_positive/.true_min/.inf/.nan,
folded via the shared lowerNumericLimit intercept + builder.constFloat.

- examples/0159: pins every f32/f64 accessor by untagged-union bit
  reinterpret against exact IEEE-754 hex (true_min read before any
  arithmetic — FTZ/DAZ), plus the defining-property checks
  ((1+eps)!=1 / (1+eps/2)==1, inf>max, min==-max, true_min<min_positive,
  true_min>0, nan!=nan).
- examples/0160: float-only accessor on an int (s32.epsilon/u8.inf/
  s64.true_min) and any accessor on a non-numeric type compile-error
  cleanly (exit 1, pinned stderr).
- type_resolver.test.zig: floatLimitFor bit-pattern + property tests for
  f32/f64, isLimitField coverage, null for non-float/non-limit fields.
- specs.md Numeric Limits: float accessors + the min=-max / min_positive=
  smallest-normal / epsilon=ULP-of-1.0 / true_min=smallest-subnormal
  clarifications, with the mandatory FTZ/DAZ flush-to-zero caveat.
  readme.md overview updated.
This commit is contained in:
agra
2026-06-04 23:30:41 +03:00
parent b7069801bd
commit 463557990f
11 changed files with 310 additions and 1 deletions

View File

@@ -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_min<min_pos {}\n", f64.true_min < f64.min_positive); // true
print("true_min>0 {}\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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -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_min<min_pos true
true_min>0 true
nan!=nan true
typed eps bits true

View File

@@ -0,0 +1 @@
1

View File

@@ -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;
| ^^^^^^^^^^^^^^^^

View File

@@ -90,7 +90,13 @@ Options:
a compile-time constant of that type: `s64.max``9223372036854775807`, 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` `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 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 ### Declarations

View File

@@ -283,6 +283,64 @@ n := u64.max; // 18446744073709551615 (all-ones)
`string`, a pointer, a `struct`, `void`, an `enum`) is a compile error, never `string`, a pointer, a `struct`, `void`, an `enum`) is a compile error, never
a silent value. 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 ### 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). 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).

View File

@@ -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("s64", "len") == null);
try std.testing.expect(TypeResolver.integerLimitFor("u8", "epsilon") == 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);
}