tally/engine/src/number.zig

1483 lines
54 KiB
Zig

//! The Tally numeric model: an exact tier over an inexact fallback.
//!
//! See design.md 2.7. A `Number` is either an exact `Rational` or an inexact
//! `f64`. Arithmetic stays exact as long as it can, and falls back to floating
//! point only where a result cannot be rational (transcendentals, irrational
//! roots, non-integer powers).
//!
//! Two invariants make the `exact` tag trustworthy:
//!
//! 1. **Contagion.** Any operation with an inexact operand yields an inexact
//! result. `exact` therefore means "no rounding has occurred anywhere in this
//! value's history", not "happens to look clean right now".
//! 2. **No re-exactification.** An inexact value is never converted back to
//! exact, even when it looks like a whole number.
//!
//! Growth is bounded: exact results whose denominator exceeds
//! `max_denominator_bits` are demoted to inexact rather than allowed to consume
//! unbounded memory. Degradation, not failure.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Rational = @import("Rational.zig");
const grouping = @import("grouping.zig");
/// The errors arithmetic on `Number` can produce, which are `Rational`'s: this tier
/// adds no failure of its own.
///
/// There used to be a `toCalcError` here translating these into a single engine-wide
/// error set, because every module returned that one set. The names it translated
/// away were the more useful ones: `ExponentTooLarge` became `Overflow` and
/// `NegativeRoot` became `DomainError`, so `sqrt(-1)` reported "domain error" when
/// the engine knew exactly what was wrong.
pub const Error = Rational.Error;
/// Denominator size at which an exact result is demoted to inexact.
///
/// Interactive single calculations do not approach this; the cap exists so that
/// a pathological chain of divisions degrades gracefully instead of exhausting
/// memory. Starting value, to be revisited with measurements.
pub const max_denominator_bits: usize = 4096;
pub const Number = union(enum) {
exact: Rational,
inexact: f64,
// -- Construction --
/// Wrap an exact value, applying the growth cap: a rational whose
/// denominator exceeds `max_denominator_bits` is demoted to inexact rather
/// than stored. Takes ownership of `value`.
///
/// This is the only way to build an exact `Number`, so the cap holds for
/// constructed values and not just for arithmetic results.
pub fn fromRational(allocator: Allocator, value: Rational) Number {
if (value.denBitCount() <= max_denominator_bits) {
return .{ .exact = value };
}
var v = value;
const f = v.toFloat(allocator);
v.deinit();
return .{ .inexact = f };
}
pub fn fromFloat(value: f64) Number {
return .{ .inexact = value };
}
pub fn fromInt(allocator: Allocator, value: anytype) Error!Number {
return .{ .exact = try Rational.initInt(allocator, value) };
}
/// Parse a decimal literal exactly. `0.1` becomes `1/10`, not a float.
pub fn parse(allocator: Allocator, text: []const u8) Error!Number {
return .{ .exact = try Rational.parseDecimal(allocator, text) };
}
pub fn deinit(self: *Number) void {
switch (self.*) {
.exact => |*r| r.deinit(),
.inexact => {},
}
}
pub fn clone(self: Number) Error!Number {
return switch (self) {
.exact => |r| .{ .exact = try r.clone() },
.inexact => |f| .{ .inexact = f },
};
}
/// Copy into a different allocator. See `Rational.cloneWith`.
pub fn cloneWith(self: Number, allocator: Allocator) Error!Number {
return switch (self) {
.exact => |r| .{ .exact = try r.cloneWith(allocator) },
.inexact => |f| .{ .inexact = f },
};
}
// -- Queries --
/// Collapse to f64 for display or for handing to a float-only operation.
pub fn toFloat(self: Number, allocator: Allocator) f64 {
return switch (self) {
.exact => |r| r.toFloat(allocator),
.inexact => |f| f,
};
}
pub fn isZero(self: Number) bool {
return switch (self) {
.exact => |r| r.isZero(),
.inexact => |f| f == 0,
};
}
pub fn isNegative(self: Number) bool {
return switch (self) {
.exact => |r| r.isNegative(),
.inexact => |f| f < 0,
};
}
/// True for an exact whole number. Inexact values are never reported as
/// integers, because we cannot know whether rounding produced the
/// integer-looking value.
pub fn isExactInteger(self: Number) bool {
return switch (self) {
.exact => |r| r.isInteger(),
.inexact => false,
};
}
/// The exact value as an i64, when it is a whole number that fits.
/// Used for things like integer exponents and factorial arguments.
pub fn asExactInt(self: Number, comptime T: type) ?T {
return switch (self) {
.exact => |r| blk: {
if (!r.isInteger()) break :blk null;
break :blk r.num.toInt(T) catch null;
},
.inexact => null,
};
}
// -- Arithmetic (with contagion) --
/// Shape of a binary operation: exact when both operands are exact,
/// otherwise fall back to floats.
fn binary(
allocator: Allocator,
a: Number,
b: Number,
comptime exactOp: fn (Allocator, Rational, Rational) Error!Rational,
comptime floatOp: fn (f64, f64) f64,
) Error!Number {
if (a == .exact and b == .exact) {
const result = try exactOp(allocator, a.exact, b.exact);
return fromRational(allocator, result);
}
return .{ .inexact = floatOp(a.toFloat(allocator), b.toFloat(allocator)) };
}
// The `*Float` wrappers below exist because Zig operators and builtins are
// not first-class values: `x + y` and `@mod(x, y)` cannot be handed to
// `binary` as a `fn (f64, f64) f64`. `std.math` offers no operator
// equivalents, and its `floor`, `ceil`, and `round` are declared
// `pub inline fn (value: anytype)`, which is both generic and inline, so
// they will not coerce to a concrete function type either. A named wrapper
// per operation is the explicit way to name the float side.
fn addFloat(x: f64, y: f64) f64 {
return x + y;
}
fn subFloat(x: f64, y: f64) f64 {
return x - y;
}
fn mulFloat(x: f64, y: f64) f64 {
return x * y;
}
fn divFloat(x: f64, y: f64) f64 {
return x / y;
}
pub fn add(allocator: Allocator, a: Number, b: Number) Error!Number {
return binary(allocator, a, b, Rational.add, addFloat);
}
pub fn sub(allocator: Allocator, a: Number, b: Number) Error!Number {
return binary(allocator, a, b, Rational.sub, subFloat);
}
pub fn mul(allocator: Allocator, a: Number, b: Number) Error!Number {
return binary(allocator, a, b, Rational.mul, mulFloat);
}
/// Division. Division by an exact zero is an error; division by an inexact
/// zero follows IEEE semantics and yields infinity, matching the float tier.
pub fn div(allocator: Allocator, a: Number, b: Number) Error!Number {
if (b == .exact and b.exact.isZero()) return Error.DivisionByZero;
return binary(allocator, a, b, Rational.div, divFloat);
}
pub fn negate(allocator: Allocator, a: Number) Error!Number {
return switch (a) {
.exact => |r| .{ .exact = try Rational.negate(allocator, r) },
.inexact => |f| .{ .inexact = -f },
};
}
pub fn abs(allocator: Allocator, a: Number) Error!Number {
return switch (a) {
.exact => |r| .{ .exact = try Rational.abs(allocator, r) },
.inexact => |f| .{ .inexact = @abs(f) },
};
}
/// Exponentiation. Stays exact only when both the base is exact and the
/// exponent is an exact integer; a fractional exponent generally produces an
/// irrational result, so it falls back.
pub fn pow(allocator: Allocator, base: Number, exponent: Number) Error!Number {
if (base == .exact) {
if (exponent.asExactInt(i64)) |e| {
if (base.exact.isZero() and e < 0) return Error.DivisionByZero;
const result = try Rational.powInt(allocator, base.exact, e);
return fromRational(allocator, result);
}
}
return .{ .inexact = std.math.pow(f64, base.toFloat(allocator), exponent.toFloat(allocator)) };
}
/// Square root. Exact for perfect rational squares (`sqrt(4)` is 2), inexact
/// otherwise (`sqrt(2)`), per design.md 2.7.4.
///
/// A negative input is `error.NegativeRoot`. The domain rule lives here rather
/// than in each caller: it used to be checked in three places (here, in
/// `Rational.sqrtExact`, and again in the evaluator, which returned
/// `UnknownFunction` for it so `sqrt(-1)` reported "unknown function"). The
/// float fallback would otherwise return a silent NaN.
pub fn sqrt(allocator: Allocator, a: Number) Error!Number {
if (a.isNegative()) return Error.NegativeRoot;
if (a == .exact) {
if (try Rational.sqrtExact(allocator, a.exact)) |root| {
return fromRational(allocator, root);
}
}
return .{ .inexact = @sqrt(a.toFloat(allocator)) };
}
/// Shape of a unary operation that has an exact implementation.
fn unary(
allocator: Allocator,
a: Number,
comptime exactOp: fn (Allocator, Rational) Error!Rational,
comptime floatOp: fn (f64) f64,
) Error!Number {
return switch (a) {
.exact => |r| fromRational(allocator, try exactOp(allocator, r)),
.inexact => |f| .{ .inexact = floatOp(f) },
};
}
// Wrapped for the reason given above `addFloat`.
fn floorFloat(x: f64) f64 {
return @floor(x);
}
fn ceilFloat(x: f64) f64 {
return @ceil(x);
}
fn roundFloat(x: f64) f64 {
return @round(x);
}
pub fn floor(allocator: Allocator, a: Number) Error!Number {
return unary(allocator, a, Rational.floor, floorFloat);
}
pub fn ceil(allocator: Allocator, a: Number) Error!Number {
return unary(allocator, a, Rational.ceil, ceilFloat);
}
pub fn round(allocator: Allocator, a: Number) Error!Number {
return unary(allocator, a, Rational.round, roundFloat);
}
fn modFloat(x: f64, y: f64) f64 {
return @mod(x, y);
}
/// Remainder, taking the sign of the divisor (matching `@mod`).
pub fn mod(allocator: Allocator, a: Number, b: Number) Error!Number {
if (b == .exact and b.exact.isZero()) return Error.DivisionByZero;
if (b == .inexact and b.inexact == 0) return Error.DivisionByZero;
return binary(allocator, a, b, Rational.mod, modFloat);
}
/// Exact factorial of a non-negative integer. Returns null when the input is
/// not a non-negative exact integer, letting the caller raise a domain error.
pub fn factorial(allocator: Allocator, a: Number) Error!?Number {
const n = a.asExactInt(i64) orelse return null;
if (n < 0) return null;
const result = try Rational.factorial(allocator, @intCast(n));
return fromRational(allocator, result);
}
/// The larger of two values, preserving exactness when both are exact.
pub fn max(allocator: Allocator, a: Number, b: Number) Error!Number {
return if ((try order(allocator, a, b)) == .lt) b.clone() else a.clone();
}
/// The smaller of two values, preserving exactness when both are exact.
pub fn min(allocator: Allocator, a: Number, b: Number) Error!Number {
return if ((try order(allocator, a, b)) == .gt) b.clone() else a.clone();
}
// -- Comparison --
pub fn order(allocator: Allocator, a: Number, b: Number) Error!std.math.Order {
if (a == .exact and b == .exact) {
return Rational.order(allocator, a.exact, b.exact);
}
const x = a.toFloat(allocator);
const y = b.toFloat(allocator);
if (x < y) return .lt;
if (x > y) return .gt;
return .eq;
}
pub fn eql(allocator: Allocator, a: Number, b: Number) Error!bool {
return (try order(allocator, a, b)) == .eq;
}
// -- Display --
//
// A value renders itself, and the caller supplies the budget. No digit count
// lives in the engine: `FormatOptions` has no defaults, so a frontend must
// state its own rather than inherit one chosen for an 80-column terminal.
// This is NFR-9.9 ("display precision is a separate decision from compute
// precision") taken literally, and it is what lets an Android screen and a
// piped CLI disagree without either of them patching the engine.
//
// The one rule that stays here is `f64_exact_integer_limit`, because it is a
// fact about the value rather than a preference about the screen.
/// How a value is rendered.
///
/// Deliberately without defaults: a frontend that forgets to decide gets a
/// compile error instead of silently inheriting someone else's screen.
pub const FormatOptions = struct {
/// Fractional digits the text may use.
///
/// One rule for both tiers: an exact expansion is rounded here, and a
/// float whose shortest round-trip form needs more than this abbreviates
/// to scientific rather than being silently shortened. Exact arithmetic
/// can justify more digits than f64's ~17 significant ones.
fraction_digits: usize,
/// Integer digits shown in full before the text abbreviates to
/// scientific notation. `null` never abbreviates, which is what a
/// clipboard or a file wants (NFR-9.9, requirements.md line 237).
max_integer_digits: ?usize,
/// Significant digits kept in the scientific form.
significant_digits: usize,
/// Thousands separators in the integer part. A fractional part is never
/// grouped.
separators: bool,
};
/// Rendered text, plus whether the budget cost anything.
pub const Rendered = struct {
/// Caller owns the memory.
text: []u8,
/// True when the text is not the whole value: rounded away at
/// `fraction_digits`, or abbreviated to scientific notation.
///
/// This describes the TEXT, not the value. Whether the value is exact is
/// `number == .exact`, and the two are independent: an exact 1/3 renders
/// truncated, and an inexact 0.5 renders whole. A frontend marking a
/// result as approximate wants both facts, which is why this is not a
/// single `exact` flag trying to mean either.
truncated: bool,
pub fn deinit(self: Rendered, allocator: Allocator) void {
allocator.free(self.text);
}
};
/// Past 2^53 an f64 no longer distinguishes consecutive integers, so the
/// trailing digits of a fixed rendering would be invented. Not a display
/// preference, so not the caller's to set: the digits are not there.
///
/// Exact values have no such cliff and print in full at any magnitude, which
/// is the entire point of the tier (NFR-9.1, requirements.md line 232).
const f64_exact_integer_limit: f64 = 9007199254740992.0; // 2^53
/// Render for display. Caller owns `Rendered.text`.
pub fn render(self: Number, allocator: Allocator, options: FormatOptions) Error!Rendered {
return switch (self) {
.exact => |r| renderExact(r, allocator, options),
.inexact => |f| renderInexact(f, allocator, options),
};
}
fn renderExact(r: Rational, allocator: Allocator, options: FormatOptions) Error!Rendered {
const decimal = try r.toDecimalString(allocator, options.fraction_digits);
const abbreviate = blk: {
if (options.max_integer_digits) |limit| {
if (grouping.integerDigitCount(decimal.text) > limit) break :blk true;
}
// The other end of the same problem: a value below the fractional
// budget renders as all zeros, which loses it completely rather than
// merely rounding it (2^-70 as "0.00000000000000000000").
break :blk isZeroText(decimal.text) and !r.isZero();
};
if (abbreviate) {
allocator.free(decimal.text);
return .{
.text = try r.toScientificString(allocator, options.significant_digits),
.truncated = true,
};
}
if (!options.separators) return .{ .text = decimal.text, .truncated = !decimal.exact };
defer allocator.free(decimal.text);
return .{ .text = try groupText(allocator, decimal.text), .truncated = !decimal.exact };
}
fn renderInexact(f: f64, allocator: Allocator, options: FormatOptions) Error!Rendered {
// An f64's decimal text has a known upper bound, so the conversion runs on
// the stack and only the final text is allocated. That is what keeps a
// caller's `FixedBufferAllocator` sized for the text it asked for rather
// than for the widest thing an f64 can spell (347 bytes, for a subnormal).
var stack: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined;
// "inf", "-inf" and "nan" are the whole of what the value is.
if (!std.math.isFinite(f)) {
return dupeText(allocator, printFloat(&stack, "{d}", f), false);
}
if (@abs(f) >= f64_exact_integer_limit) {
return dupeText(allocator, printFloat(&stack, "{e}", f), true);
}
// Shortest round-trip: every digit of `fixed` is a digit the f64 has.
const fixed = printFloat(&stack, "{d}", f);
const abbreviate = blk: {
if (options.max_integer_digits) |limit| {
if (grouping.integerDigitCount(fixed) > limit) break :blk true;
}
break :blk fractionDigitCount(fixed) > options.fraction_digits;
};
if (abbreviate) {
// Reuses `stack`, so `fixed` is dead from here. The scientific form is
// far shorter than the fixed one it replaces, so it still fits.
return dupeText(allocator, printFloat(&stack, "{e}", f), true);
}
if (!options.separators) return dupeText(allocator, fixed, false);
return .{ .text = try groupText(allocator, fixed), .truncated = false };
}
/// Format into a buffer already known to be large enough for any f64.
fn printFloat(buf: []u8, comptime spec: []const u8, f: f64) []const u8 {
return std.fmt.bufPrint(buf, spec, .{f}) catch
@panic("f64 text exceeded std.fmt.float.bufferSize");
}
fn dupeText(allocator: Allocator, text: []const u8, truncated: bool) Error!Rendered {
return .{
.text = allocator.dupe(u8, text) catch return Error.OutOfMemory,
.truncated = truncated,
};
}
/// Insert thousands separators into the integer part, leaving any sign and
/// fractional part alone.
fn groupText(allocator: Allocator, text: []const u8) Error![]u8 {
const len = grouping.lengthOf(text);
if (len == text.len) {
return allocator.dupe(u8, text) catch Error.OutOfMemory;
}
const out = try allocator.alloc(u8, len);
var w = std.Io.Writer.fixed(out);
grouping.print(&w, text) catch @panic("grouping.lengthOf disagreed with grouping.print");
std.debug.assert(w.end == len);
return out;
}
/// True when decimal text carries no significant digit, i.e. it is some
/// spelling of zero ("0", "0.00", "-0.000").
fn isZeroText(text: []const u8) bool {
for (text) |ch| {
if (ch >= '1' and ch <= '9') return false;
}
return true;
}
/// Fractional digits in decimal text, which is the budget the text spends.
///
/// The float arm's form of the check the exact arm makes with `isZeroText`:
/// both ask "did this value need more fractional digits than the caller
/// allows". The exact arm can ask after the fact because `toDecimalString`
/// rounds at the budget and a value below it rounds to all zeros. A float's
/// shortest round-trip text is never rounded, so its digits are counted
/// instead, and a value needing more than the budget abbreviates rather than
/// being silently shortened.
fn fractionDigitCount(text: []const u8) usize {
const dot = std.mem.indexOfScalar(u8, text, '.') orelse return 0;
return text.len - dot - 1;
}
};
// -- Tests --
const testing = std.testing;
const alloc = testing.allocator;
/// Render `n` at a `digits` fractional budget and check the text, plus whether
/// the text is the exact value.
///
/// That second question is the composition a frontend does: the text is the whole
/// truth only when the value is exact AND the budget did not round it away.
/// `render` reports the two facts separately because they are separate; this
/// helper joins them so the arithmetic tests below can state one expectation.
fn expectDecimal(expected: []const u8, expected_exact: bool, n: Number, digits: usize) !void {
const shown = try n.render(alloc, .{
.fraction_digits = digits,
.max_integer_digits = null,
.significant_digits = 17,
.separators = false,
});
defer shown.deinit(alloc);
try testing.expectEqualStrings(expected, shown.text);
try testing.expectEqual(expected_exact, n == .exact and !shown.truncated);
}
test "parse produces an exact value" {
var n = try Number.parse(alloc, "0.1");
defer n.deinit();
try testing.expect(n == .exact);
try expectDecimal("0.1", true, n, 20);
}
test "fromFloat produces an inexact value" {
var n = Number.fromFloat(0.5);
defer n.deinit();
try testing.expect(n != .exact);
}
test "exact + exact stays exact: the 0.1 + 0.2 case" {
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = try Number.parse(alloc, "0.2");
defer b.deinit();
var sum = try Number.add(alloc, a, b);
defer sum.deinit();
try testing.expect(sum == .exact);
try expectDecimal("0.3", true, sum, 20);
}
test "contagion: inexact operand makes the result inexact" {
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = Number.fromFloat(0.2);
defer b.deinit();
var sum = try Number.add(alloc, a, b);
defer sum.deinit();
try testing.expect(sum != .exact);
var product = try Number.mul(alloc, b, a);
defer product.deinit();
try testing.expect(product != .exact);
}
test "contagion: an inexact value is never re-exactified" {
// 0.5 * 2 == 1.0 exactly in f64, but the result must stay inexact because
// we cannot know the history of the inexact operand.
var a = Number.fromFloat(0.5);
defer a.deinit();
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var product = try Number.mul(alloc, a, two);
defer product.deinit();
try testing.expect(product != .exact);
try testing.expectEqual(@as(f64, 1.0), product.toFloat(alloc));
try testing.expect(!product.isExactInteger());
}
test "contagion propagates through a chain" {
var exact = try Number.parse(alloc, "1.5");
defer exact.deinit();
var inexact = Number.fromFloat(2.0);
defer inexact.deinit();
var step1 = try Number.add(alloc, exact, inexact);
defer step1.deinit();
var step2 = try Number.mul(alloc, step1, exact);
defer step2.deinit();
var step3 = try Number.sub(alloc, step2, exact);
defer step3.deinit();
try testing.expect(step3 != .exact);
}
test "sub and mul stay exact" {
var a = try Number.parse(alloc, "1.1");
defer a.deinit();
var b = try Number.parse(alloc, "2.2");
defer b.deinit();
var sum = try Number.add(alloc, a, b);
defer sum.deinit();
try expectDecimal("3.3", true, sum, 20);
var diff = try Number.sub(alloc, b, a);
defer diff.deinit();
try expectDecimal("1.1", true, diff, 20);
}
test "div: exact thirds and the round trip back to one" {
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var three = try Number.fromInt(alloc, 3);
defer three.deinit();
var third = try Number.div(alloc, one, three);
defer third.deinit();
try testing.expect(third == .exact);
var back = try Number.mul(alloc, third, three);
defer back.deinit();
try testing.expect(back == .exact);
try expectDecimal("1", true, back, 20);
}
test "div by exact zero errors" {
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var zero = try Number.fromInt(alloc, 0);
defer zero.deinit();
try testing.expectError(Error.DivisionByZero, Number.div(alloc, one, zero));
}
test "div by inexact zero follows IEEE semantics" {
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var zero = Number.fromFloat(0.0);
defer zero.deinit();
var result = try Number.div(alloc, one, zero);
defer result.deinit();
try testing.expect(std.math.isPositiveInf(result.toFloat(alloc)));
}
test "negate and abs preserve exactness" {
var a = try Number.parse(alloc, "0.25");
defer a.deinit();
var n = try Number.negate(alloc, a);
defer n.deinit();
try testing.expect(n == .exact);
try expectDecimal("-0.25", true, n, 20);
var b = try Number.abs(alloc, n);
defer b.deinit();
try testing.expect(b == .exact);
try expectDecimal("0.25", true, b, 20);
}
test "negate and abs preserve inexactness" {
var a = Number.fromFloat(-1.5);
defer a.deinit();
var b = try Number.abs(alloc, a);
defer b.deinit();
try testing.expect(b != .exact);
try testing.expectEqual(@as(f64, 1.5), b.toFloat(alloc));
}
test "pow: integer exponent stays exact" {
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var ten = try Number.fromInt(alloc, 10);
defer ten.deinit();
var p = try Number.pow(alloc, two, ten);
defer p.deinit();
try testing.expect(p == .exact);
try expectDecimal("1024", true, p, 20);
}
test "pow: 2^53 + 1 is exact, unlike f64" {
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var fiftythree = try Number.fromInt(alloc, 53);
defer fiftythree.deinit();
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var p = try Number.pow(alloc, two, fiftythree);
defer p.deinit();
var sum = try Number.add(alloc, p, one);
defer sum.deinit();
try expectDecimal("9007199254740993", true, sum, 0);
}
test "pow: fractional exponent falls back to inexact" {
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var half = try Number.parse(alloc, "0.5");
defer half.deinit();
var p = try Number.pow(alloc, two, half);
defer p.deinit();
try testing.expect(p != .exact);
try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), p.toFloat(alloc), 1e-15);
}
test "pow: negative integer exponent stays exact" {
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var neg = try Number.fromInt(alloc, -3);
defer neg.deinit();
var p = try Number.pow(alloc, two, neg);
defer p.deinit();
try testing.expect(p == .exact);
try expectDecimal("0.125", true, p, 20);
}
test "pow: zero to a negative power errors" {
var zero = try Number.fromInt(alloc, 0);
defer zero.deinit();
var neg = try Number.fromInt(alloc, -1);
defer neg.deinit();
try testing.expectError(Error.DivisionByZero, Number.pow(alloc, zero, neg));
}
test "sqrt: perfect squares stay exact, others fall back" {
var four = try Number.fromInt(alloc, 4);
defer four.deinit();
var r = try Number.sqrt(alloc, four);
defer r.deinit();
try testing.expect(r == .exact);
try expectDecimal("2", true, r, 20);
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var r2 = try Number.sqrt(alloc, two);
defer r2.deinit();
try testing.expect(r2 != .exact);
try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), r2.toFloat(alloc), 1e-15);
}
test "sqrt: a negative input is a domain error, not a silent NaN" {
// This used to return an inexact NaN, and the evaluator separately rejected
// negatives with UnknownFunction, so `sqrt(-1)` reported "unknown function".
// The rule now lives here and nowhere else.
var neg = try Number.fromInt(alloc, -4);
defer neg.deinit();
try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, neg));
var inexact_neg = Number.fromFloat(-4.0);
defer inexact_neg.deinit();
try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, inexact_neg));
// Zero and positives are unaffected.
var zero = try Number.fromInt(alloc, 0);
defer zero.deinit();
var root_zero = try Number.sqrt(alloc, zero);
defer root_zero.deinit();
try testing.expect(root_zero == .exact);
}
test "sqrt: a negative input surfaces as NegativeRoot, not a vaguer error" {
var neg = try Number.fromInt(alloc, -4);
defer neg.deinit();
try testing.expectError(Error.NegativeRoot, Number.sqrt(alloc, neg));
}
test "asExactInt" {
var a = try Number.fromInt(alloc, 42);
defer a.deinit();
try testing.expectEqual(@as(?i64, 42), a.asExactInt(i64));
var frac = try Number.parse(alloc, "1.5");
defer frac.deinit();
try testing.expect(frac.asExactInt(i64) == null);
var inexact = Number.fromFloat(3.0);
defer inexact.deinit();
try testing.expect(inexact.asExactInt(i64) == null);
}
test "isExactInteger" {
var a = try Number.fromInt(alloc, 7);
defer a.deinit();
try testing.expect(a.isExactInteger());
var b = try Number.parse(alloc, "7.5");
defer b.deinit();
try testing.expect(!b.isExactInteger());
}
test "order and eql across tiers" {
var a = try Number.parse(alloc, "0.5");
defer a.deinit();
var b = Number.fromFloat(0.5);
defer b.deinit();
try testing.expect(try Number.eql(alloc, a, b));
var c = try Number.fromInt(alloc, 1);
defer c.deinit();
try testing.expectEqual(std.math.Order.lt, try Number.order(alloc, a, c));
try testing.expectEqual(std.math.Order.gt, try Number.order(alloc, c, a));
}
test "demotion: an oversized denominator degrades to inexact" {
// Build 1/2^n with n past the cap by repeated halving.
var value = try Number.fromInt(alloc, 1);
defer value.deinit();
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var i: usize = 0;
while (i < max_denominator_bits + 64) : (i += 1) {
var next = try Number.div(alloc, value, two);
value.deinit();
value = next;
if (value != .exact) break;
_ = &next;
}
try testing.expect(value != .exact);
}
test "demotion: normal values stay well under the cap" {
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = try Number.parse(alloc, "1.0000001");
defer b.deinit();
var product = try Number.mul(alloc, a, b);
defer product.deinit();
try testing.expect(product == .exact);
}
test "rendering: inexact values are always flagged approximate" {
var a = Number.fromFloat(0.5);
defer a.deinit();
try expectDecimal("0.5", false, a, 20);
}
test "rendering: exact non-terminating values are flagged approximate" {
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var three = try Number.fromInt(alloc, 3);
defer three.deinit();
var third = try Number.div(alloc, one, three);
defer third.deinit();
try expectDecimal("0.333", false, third, 3);
}
test "toFloat: exact to float uses a single correct rounding" {
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = try Number.parse(alloc, "0.2");
defer b.deinit();
var sum = try Number.add(alloc, a, b);
defer sum.deinit();
try testing.expectEqual(@as(f64, 0.3), sum.toFloat(alloc));
}
test "clone preserves the tier" {
var a = try Number.parse(alloc, "0.75");
defer a.deinit();
var b = try a.clone();
defer b.deinit();
try testing.expect(b == .exact);
try testing.expect(try Number.eql(alloc, a, b));
var c = Number.fromFloat(1.25);
defer c.deinit();
var d = try c.clone();
defer d.deinit();
try testing.expect(d != .exact);
}
test "floor/ceil/round preserve exactness" {
var a = try Number.parse(alloc, "3.7");
defer a.deinit();
var f = try Number.floor(alloc, a);
defer f.deinit();
try testing.expect(f == .exact);
try expectDecimal("3", true, f, 20);
var c = try Number.ceil(alloc, a);
defer c.deinit();
try testing.expect(c == .exact);
try expectDecimal("4", true, c, 20);
var r = try Number.round(alloc, a);
defer r.deinit();
try testing.expect(r == .exact);
try expectDecimal("4", true, r, 20);
}
test "floor/ceil/round on inexact stay inexact" {
var a = Number.fromFloat(3.7);
defer a.deinit();
var f = try Number.floor(alloc, a);
defer f.deinit();
try testing.expect(f != .exact);
try testing.expectEqual(@as(f64, 3.0), f.toFloat(alloc));
var c = try Number.ceil(alloc, a);
defer c.deinit();
try testing.expect(c != .exact);
try testing.expectEqual(@as(f64, 4.0), c.toFloat(alloc));
var r = try Number.round(alloc, a);
defer r.deinit();
try testing.expect(r != .exact);
try testing.expectEqual(@as(f64, 4.0), r.toFloat(alloc));
}
test "mod with an inexact operand stays inexact" {
var a = Number.fromFloat(10.0);
defer a.deinit();
var b = try Number.fromInt(alloc, 3);
defer b.deinit();
var m = try Number.mod(alloc, a, b);
defer m.deinit();
try testing.expect(m != .exact);
try testing.expectEqual(@as(f64, 1.0), m.toFloat(alloc));
// And with the inexact value on the right.
var c = try Number.fromInt(alloc, 10);
defer c.deinit();
var d = Number.fromFloat(3.0);
defer d.deinit();
var m2 = try Number.mod(alloc, c, d);
defer m2.deinit();
try testing.expect(m2 != .exact);
try testing.expectEqual(@as(f64, 1.0), m2.toFloat(alloc));
}
test "mod preserves exactness and rejects a zero divisor" {
var a = try Number.fromInt(alloc, 10);
defer a.deinit();
var b = try Number.fromInt(alloc, 3);
defer b.deinit();
var m = try Number.mod(alloc, a, b);
defer m.deinit();
try testing.expect(m == .exact);
try expectDecimal("1", true, m, 20);
var zero = try Number.fromInt(alloc, 0);
defer zero.deinit();
try testing.expectError(Error.DivisionByZero, Number.mod(alloc, a, zero));
var fzero = Number.fromFloat(0);
defer fzero.deinit();
try testing.expectError(Error.DivisionByZero, Number.mod(alloc, a, fzero));
}
test "factorial is exact and unbounded" {
var five = try Number.fromInt(alloc, 5);
defer five.deinit();
var f = (try Number.factorial(alloc, five)).?;
defer f.deinit();
try testing.expect(f == .exact);
try expectDecimal("120", true, f, 20);
// 171! is beyond f64 but fine here.
var big = try Number.fromInt(alloc, 171);
defer big.deinit();
var bf = (try Number.factorial(alloc, big)).?;
defer bf.deinit();
try testing.expect(bf == .exact);
}
test "factorial rejects non-integers and negatives" {
var frac = try Number.parse(alloc, "2.5");
defer frac.deinit();
try testing.expect((try Number.factorial(alloc, frac)) == null);
var neg = try Number.fromInt(alloc, -1);
defer neg.deinit();
try testing.expect((try Number.factorial(alloc, neg)) == null);
var inexact = Number.fromFloat(5);
defer inexact.deinit();
try testing.expect((try Number.factorial(alloc, inexact)) == null);
}
test "max and min preserve exactness" {
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = try Number.parse(alloc, "0.2");
defer b.deinit();
var hi = try Number.max(alloc, a, b);
defer hi.deinit();
try testing.expect(hi == .exact);
try expectDecimal("0.2", true, hi, 20);
var lo = try Number.min(alloc, a, b);
defer lo.deinit();
try testing.expect(lo == .exact);
try expectDecimal("0.1", true, lo, 20);
}
test "max and min with an inexact operand return that operand as-is" {
var a = try Number.fromInt(alloc, 1);
defer a.deinit();
var b = Number.fromFloat(2.0);
defer b.deinit();
var hi = try Number.max(alloc, a, b);
defer hi.deinit();
try testing.expect(hi != .exact);
try testing.expectEqual(@as(f64, 2.0), hi.toFloat(alloc));
}
// -- Allocation-failure safety --
//
// Mirrors the sweep in Rational.zig: fail the Nth allocation for every N, and
// let testing.allocator's leak detection verify that partially-built values are
// released. This is what actually validates the cleanup paths; merely executing
// them proves nothing.
fn oomSweep(comptime body: fn (Allocator) anyerror!void) !void {
var fail_index: usize = 0;
while (fail_index < 512) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = fail_index });
if (body(failing.allocator())) |_| {
return;
} else |err| {
if (err != error.OutOfMemory) return err;
}
}
return error.OomSweepNeverCompleted;
}
fn bodyExactArithmetic(a: Allocator) anyerror!void {
var x = try Number.parse(a, "0.1");
defer x.deinit();
var y = try Number.parse(a, "0.2");
defer y.deinit();
var sum = try Number.add(a, x, y);
defer sum.deinit();
var diff = try Number.sub(a, x, y);
defer diff.deinit();
var prod = try Number.mul(a, x, y);
defer prod.deinit();
var quot = try Number.div(a, x, y);
defer quot.deinit();
var neg = try Number.negate(a, x);
defer neg.deinit();
var magnitude = try Number.abs(a, neg);
defer magnitude.deinit();
_ = try Number.order(a, x, y);
}
fn bodyPowSqrtFactorial(a: Allocator) anyerror!void {
var base = try Number.fromInt(a, 4);
defer base.deinit();
var exp = try Number.fromInt(a, 3);
defer exp.deinit();
var p = try Number.pow(a, base, exp);
defer p.deinit();
var r = try Number.sqrt(a, base);
defer r.deinit();
if (try Number.factorial(a, exp)) |f| {
var value = f;
value.deinit();
}
}
fn bodyRoundingAndSelection(a: Allocator) anyerror!void {
var x = try Number.parse(a, "-3.75");
defer x.deinit();
var y = try Number.fromInt(a, 2);
defer y.deinit();
var f = try Number.floor(a, x);
defer f.deinit();
var c = try Number.ceil(a, x);
defer c.deinit();
var rounded = try Number.round(a, x);
defer rounded.deinit();
var m = try Number.mod(a, x, y);
defer m.deinit();
var hi = try Number.max(a, x, y);
defer hi.deinit();
var lo = try Number.min(a, x, y);
defer lo.deinit();
var copy = try x.clone();
defer copy.deinit();
}
fn bodyRendering(a: Allocator) anyerror!void {
var x = try Number.parse(a, "0.1");
defer x.deinit();
var three = try Number.fromInt(a, 3);
defer three.deinit();
var third = try Number.div(a, x, three);
defer third.deinit();
// 1/10 / 3 is 1/30, always exact, so the exact renderers are the ones
// under allocation pressure here.
const d = try third.exact.toDecimalString(a, 12);
a.free(d.text);
const frac = try third.exact.toFractionString(a);
a.free(frac);
_ = third.toFloat(a);
// Every branch of `render` allocates, including the abbreviating ones.
for ([_]Number.FormatOptions{ display_budget, clipboard_budget, compact_budget }) |options| {
const shown = try third.render(a, options);
shown.deinit(a);
const grouped = try x.render(a, options);
grouped.deinit(a);
const inexact = try Number.fromFloat(231677.04).render(a, options);
inexact.deinit(a);
var tiny = try Number.parse(a, "0.00000000000000000000001");
defer tiny.deinit();
const abbreviated = try tiny.render(a, options);
abbreviated.deinit(a);
}
}
test "OOM safety: exact arithmetic" {
try oomSweep(bodyExactArithmetic);
}
test "OOM safety: pow, sqrt and factorial" {
try oomSweep(bodyPowSqrtFactorial);
}
test "OOM safety: rounding, mod, max/min and clone" {
try oomSweep(bodyRoundingAndSelection);
}
test "OOM safety: rendering" {
try oomSweep(bodyRendering);
}
// -- Display tests --
//
// These moved here with the renderer, from `formatter.zig`, where the digit
// budgets were engine constants and every case had to be phrased in terms of
// whichever of five thresholds applied. A test now states its budget the way a
// frontend does.
/// What a terminal asks for: grouped, 20 fractional digits, abbreviating past 40
/// integer digits. `src/main.zig` and `src/tui.zig` declare the same thing.
const display_budget: Number.FormatOptions = .{
.fraction_digits = 20,
.max_integer_digits = 40,
.significant_digits = 17,
.separators = false,
};
/// The same, grouped, which is the form that reaches a screen.
const grouped_budget: Number.FormatOptions = .{
.fraction_digits = 20,
.max_integer_digits = 40,
.significant_digits = 17,
.separators = true,
};
/// What a clipboard asks for: every digit, no separators, never abbreviated
/// (requirements.md line 237).
const clipboard_budget: Number.FormatOptions = .{
.fraction_digits = 20,
.max_integer_digits = null,
.significant_digits = 17,
.separators = false,
};
/// What the float view asks for: single line, no separators, and 17 fractional
/// digits, which is every digit an f64 has. A value needing more than that is one
/// no row wants to show in full, such as a subnormal ULP.
const compact_budget: Number.FormatOptions = .{
.fraction_digits = 17,
.max_integer_digits = 16,
.significant_digits = 17,
.separators = false,
};
fn expectRender(
expected: []const u8,
expected_truncated: bool,
value: Number,
options: Number.FormatOptions,
) !void {
const shown = try value.render(alloc, options);
defer shown.deinit(alloc);
try testing.expectEqualStrings(expected, shown.text);
try testing.expectEqual(expected_truncated, shown.truncated);
}
fn expectRenderExact(expected: []const u8, text: []const u8, options: Number.FormatOptions) !void {
var value = try Number.parse(alloc, text);
defer value.deinit();
try expectRender(expected, false, value, options);
}
fn hasChar(s: []const u8, c: u8) bool {
return std.mem.indexOfScalar(u8, s, c) != null;
}
// -- Exact values --
test "render: exact integers group and keep every digit" {
try expectRenderExact("42", "42", grouped_budget);
try expectRenderExact("4,294,967,295", "4294967295", grouped_budget);
try expectRenderExact("-1,234,567", "-1234567", grouped_budget);
try expectRenderExact("0", "0", grouped_budget);
// The whole point of the exact tier: NOT 9.007199254740992e15.
try expectRenderExact("9,007,199,254,740,993", "9007199254740993", grouped_budget);
// 30 digits, still inside a 40-digit budget.
try expectRenderExact(
"123,456,789,012,345,678,901,234,567,890",
"123456789012345678901234567890",
grouped_budget,
);
}
test "render: the clipboard budget drops the separators, not the digits" {
try expectRenderExact("4294967295", "4294967295", clipboard_budget);
try expectRenderExact("9007199254740993", "9007199254740993", clipboard_budget);
}
test "render: exact terminating fractions print in full" {
try expectRenderExact("0.125", "0.125", grouped_budget);
try expectRenderExact("1,234,567.25", "1234567.25", grouped_budget);
try expectRenderExact("1,234,567.891", "1234567.891", grouped_budget);
// The last magnitude a 20-digit budget can show.
try expectRenderExact("0.00000000000000000001", "0.00000000000000000001", grouped_budget);
}
test "render: 0.1 + 0.2 renders as 0.3" {
var a = try Number.parse(alloc, "0.1");
defer a.deinit();
var b = try Number.parse(alloc, "0.2");
defer b.deinit();
var sum = try Number.add(alloc, a, b);
defer sum.deinit();
try expectRender("0.3", false, sum, grouped_budget);
}
test "render: a repeating expansion is rounded at the budget and flagged" {
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var three = try Number.fromInt(alloc, 3);
defer three.deinit();
var third = try Number.div(alloc, one, three);
defer third.deinit();
const shown = try third.render(alloc, grouped_budget);
defer shown.deinit(alloc);
try testing.expect(shown.truncated);
try testing.expect(std.mem.startsWith(u8, shown.text, "0.3333333333"));
// "0." plus the budget.
try testing.expectEqual(grouped_budget.fraction_digits + 2, shown.text.len);
// A smaller budget rounds sooner. Same value, different frontend.
try expectRender("0.33333333333333333", true, third, compact_budget);
}
// -- Abbreviation past the integer budget --
test "render: at the budget a value still prints in full" {
// 2^128 is 39 digits, inside the 40-digit budget, and is a value the exact
// tier exists to serve.
try expectRenderExact(
"340,282,366,920,938,463,463,374,607,431,768,211,456",
"340282366920938463463374607431768211456",
grouped_budget,
);
}
test "render: past the budget the text abbreviates and says so" {
var n = try Number.parse(alloc, "1e50");
defer n.deinit();
try expectRender("1e50", true, n, grouped_budget);
// A clipboard sets no cap, so the same value keeps all 51 digits.
const raw = try n.render(alloc, clipboard_budget);
defer raw.deinit(alloc);
try testing.expectEqual(@as(usize, 51), raw.text.len);
try testing.expect(!raw.truncated);
}
test "render: abbreviation rounds the mantissa" {
// 41 nines: rounds up and carries into a new power of ten.
var carry = try Number.parse(alloc, "99999999999999999999999999999999999999999");
defer carry.deinit();
try expectRender("1e41", true, carry, grouped_budget);
// 41 digits whose 18th is 8, so the 17th significant digit rounds 7 -> 8
// with no carry propagation.
var middle = try Number.parse(alloc, "12345678901234567800000000000000000000000");
defer middle.deinit();
try expectRender("1.2345678901234568e40", true, middle, grouped_budget);
// And rounds down when the next digit is below five.
var down = try Number.parse(alloc, "12345678901234567400000000000000000000000");
defer down.deinit();
try expectRender("1.2345678901234567e40", true, down, grouped_budget);
}
test "render: abbreviated values keep their sign and are never grouped" {
var n = try Number.parse(alloc, "-1.5e60");
defer n.deinit();
const shown = try n.render(alloc, grouped_budget);
defer shown.deinit(alloc);
try testing.expectEqualStrings("-1.5e60", shown.text);
// Commas in an exponent would corrupt it.
try testing.expect(!hasChar(shown.text, ','));
}
test "render: both ends of the range go through one scientific renderer" {
const cases = [_][2][]const u8{
// 41 digits, one past the budget.
.{ "10000000000000000000000000000000000000000", "1e40" },
.{ "12000000000000000000000000000000000000000", "1.2e40" },
.{ "-25000000000000000000000000000000000000000", "-2.5e40" },
};
for (cases) |c| {
var value = try Number.parse(alloc, c[0]);
defer value.deinit();
try expectRender(c[1], true, value, grouped_budget);
// The clipboard form still carries every digit.
try expectRender(c[0], false, value, clipboard_budget);
}
}
// -- Exact values below the fractional budget --
//
// The 20-digit budget renders anything smaller as "0.00000000000000000000",
// which destroys the value at the last step, in the one tier whose entire
// purpose is not doing that. Scientific notation is the honest form, and unlike
// the integer case the clipboard cannot be spared: there is no fixed text to
// give it.
test "render: a value below the fractional budget uses scientific notation" {
// 2^-70, exactly representable, equal to 8.470329472543003e-22.
var one = try Number.parse(alloc, "1");
defer one.deinit();
var divisor = try Number.parse(alloc, "1180591620717411303424");
defer divisor.deinit();
var tiny = try Number.div(alloc, one, divisor);
defer tiny.deinit();
try expectRender("8.4703294725430034e-22", true, tiny, grouped_budget);
try expectRender("8.4703294725430034e-22", true, tiny, clipboard_budget);
var negative = try Number.fromInt(alloc, -1);
defer negative.deinit();
var negative_tiny = try Number.div(alloc, negative, divisor);
defer negative_tiny.deinit();
try expectRender("-8.4703294725430034e-22", true, negative_tiny, grouped_budget);
}
test "render: exact zero is zero, not scientific" {
try expectRenderExact("0", "0", grouped_budget);
try expectRenderExact("0", "0", clipboard_budget);
}
// -- Inexact values --
test "render: inexact integers group like exact ones" {
try expectRender("42", false, Number.fromFloat(42.0), grouped_budget);
try expectRender("4,294,967,295", false, Number.fromFloat(4294967295.0), grouped_budget);
try expectRender("-1,234", false, Number.fromFloat(-1234.0), grouped_budget);
try expectRender("1,000,000", false, Number.fromFloat(1000000.0), grouped_budget);
try expectRender("0", false, Number.fromFloat(0.0), grouped_budget);
}
test "render: an inexact fractional value groups its integer part only" {
try expectRender("231,677.04", false, Number.fromFloat(231677.04), grouped_budget);
try expectRender("231677.04", false, Number.fromFloat(231677.04), clipboard_budget);
try expectRender("-9,876,543.21", false, Number.fromFloat(-9876543.21), grouped_budget);
// Fewer than four integer digits has nothing to group.
try expectRender("123.456", false, Number.fromFloat(123.456), grouped_budget);
try expectRender("999.99", false, Number.fromFloat(999.99), grouped_budget);
// Grouping starts at four.
try expectRender("1,000.25", false, Number.fromFloat(1000.25), grouped_budget);
try expectRender("3.14159", false, Number.fromFloat(3.14159), grouped_budget);
}
test "render: the grouped form of a float re-parses to the same value" {
// FR-1.8 accepts commas as digit separators, so the display form is valid
// input. That is what makes grouping safe to apply to results.
var value = Number.fromFloat(1234567.891);
const shown = try value.render(alloc, grouped_budget);
defer shown.deinit(alloc);
try testing.expectEqualStrings("1,234,567.891", shown.text);
var reparsed = try Number.parse(alloc, "1234567.891");
defer reparsed.deinit();
try testing.expectApproxEqAbs(@as(f64, 1234567.891), reparsed.toFloat(alloc), 1e-9);
}
test "render: past 2^53 an inexact value goes scientific whatever the budget" {
// Not the caller's decision: consecutive integers are no longer distinct up
// there, so the trailing digits of a fixed rendering would be invented.
try expectRender("1.5e16", true, Number.fromFloat(1.5e16), grouped_budget);
try expectRender("1.5e16", true, Number.fromFloat(1.5e16), clipboard_budget);
// Just below the limit it still prints in full: those digits are real.
try expectRender("9,007,199,254,740,990", false, Number.fromFloat(9007199254740992.0 - 2.0), grouped_budget);
}
test "render: an inexact value past the integer budget abbreviates" {
try expectRender("1e20", true, Number.fromFloat(1e20), grouped_budget);
try expectRender("-1e18", true, Number.fromFloat(-1e18), compact_budget);
}
test "render: an inexact value needing more digits than the budget goes scientific" {
// One rule for both arms: the text may spend at most `fraction_digits` on a
// fraction. An exact 1/3 is rounded there; a float, whose shortest form is
// never rounded, abbreviates instead of being silently shortened.
try expectRender("1.5e-21", true, Number.fromFloat(1.5e-21), grouped_budget);
// 33 fractional digits, past a 20-digit budget.
try expectRender("3.1415926535897935e-17", true, Number.fromFloat(std.math.pi * 1e-17), grouped_budget);
// 17, inside it, and the same text the exact tier gives for this magnitude.
try expectRender("0.00000000000000015", false, Number.fromFloat(1.5e-16), grouped_budget);
try expectRender("0.001", false, Number.fromFloat(0.001), grouped_budget);
}
test "render: scientific text is never grouped" {
const big = try Number.fromFloat(1.234e20).render(alloc, grouped_budget);
defer big.deinit(alloc);
try testing.expect(!hasChar(big.text, ','));
try testing.expect(hasChar(big.text, 'e'));
const tiny = try Number.fromFloat(1.5e-25).render(alloc, grouped_budget);
defer tiny.deinit(alloc);
try testing.expect(!hasChar(tiny.text, ','));
}
test "render: non-finite values render as themselves" {
try expectRender("inf", false, Number.fromFloat(std.math.inf(f64)), grouped_budget);
try expectRender("-inf", false, Number.fromFloat(-std.math.inf(f64)), grouped_budget);
try expectRender("nan", false, Number.fromFloat(std.math.nan(f64)), grouped_budget);
}
// -- The compact budget, as the float view uses it --
test "render: the compact budget keeps normal magnitudes fixed" {
try expectRender("1", false, Number.fromFloat(1.0), compact_budget);
try expectRender("3.14", false, Number.fromFloat(3.14), compact_budget);
try expectRender("0.5", false, Number.fromFloat(0.5), compact_budget);
try expectRender("-2", false, Number.fromFloat(-2.0), compact_budget);
// Just above the small-magnitude threshold.
try expectRender("0.001", false, Number.fromFloat(0.001), compact_budget);
try expectRender("0", false, Number.fromFloat(0.0), compact_budget);
}
test "render: the compact budget keeps every digit a float actually has" {
// 0.1 stored as f32 then widened. A short budget decides when to abbreviate,
// not how many of a float's own digits to show: all 17 are real.
const v: f64 = @floatCast(@as(f32, 0.1));
const shown = try Number.fromFloat(v).render(alloc, compact_budget);
defer shown.deinit(alloc);
try testing.expect(!hasChar(shown.text, 'e'));
try testing.expect(std.mem.startsWith(u8, shown.text, "0.100000001"));
}
test "render: the compact budget sends values needing more than 17 digits to scientific" {
// Smallest f32 subnormal, about 1.4e-45.
const subnormal = try Number.fromFloat(std.math.ldexp(@as(f64, 1.0), -149)).render(alloc, compact_budget);
defer subnormal.deinit(alloc);
try testing.expect(hasChar(subnormal.text, 'e'));
// f32 ULP of 1.0, 2^-23, about 1.19e-7.
const f32_ulp = try Number.fromFloat(std.math.ldexp(@as(f64, 1.0), -23)).render(alloc, compact_budget);
defer f32_ulp.deinit(alloc);
try testing.expect(hasChar(f32_ulp.text, 'e'));
// f64 ULP of 1.0, 2^-52, about 2.2e-16.
const f64_ulp = try Number.fromFloat(std.math.ldexp(@as(f64, 1.0), -52)).render(alloc, compact_budget);
defer f64_ulp.deinit(alloc);
try testing.expect(hasChar(f64_ulp.text, 'e'));
}