//! 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. Both tiers round here: an exact /// expansion is divided out to this many places, and a float whose /// shortest round-trip form is longer is rounded to it. Exact arithmetic /// can justify more digits than f64's ~17 significant ones. fraction_digits: usize, /// Where fixed notation stops being worth it, as a count of fractional /// places: 20 means a value whose first significant digit falls past the /// twentieth place abbreviates to scientific rather than spending the /// budget on leading zeros. /// /// Separate from `fraction_digits` because they answer different /// questions. A result line wants both at 20: round at twenty places, and /// abbreviate only when a value is too small to show there at all. A view /// of an f64's own expansion wants a wide budget and a narrow window (17 /// and 6), so it shows every digit the float has but sends a subnormal ULP /// to scientific instead of printing 45 places. scientific_below_exponent: 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); if (abbreviates(decimal.text, r.isZero(), options)) { 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 shortest_buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined; var rounded_buf: [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(&shortest_buf, "{d}", f), false); } if (@abs(f) >= f64_exact_integer_limit) { return dupeText(allocator, printFloat(&shortest_buf, "{e}", f), true); } // Shortest round-trip: every digit of this is a digit the f64 has. const shortest = printFloat(&shortest_buf, "{d}", f); // Past the budget it is rounded, the same as an exact expansion would be. // Trailing zeros are dropped so a rounded float and a rounded rational of // the same value produce the same text. var fixed = shortest; var rounded = false; if (fractionDigitCount(shortest) > options.fraction_digits) { fixed = trimTrailingZeros(std.fmt.float.render(&rounded_buf, f, .{ .mode = .decimal, .precision = options.fraction_digits, }) catch @panic("f64 text exceeded std.fmt.float.bufferSize")); rounded = true; } if (abbreviates(fixed, f == 0, options)) { // Reuses a buffer, 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(&shortest_buf, "{e}", f), true); } if (!options.separators) return dupeText(allocator, fixed, rounded); return .{ .text = try groupText(allocator, fixed), .truncated = rounded }; } /// Whether fixed `text` should give way to scientific notation. The one /// decision both arms make, so the tier a value came from cannot change the /// shape of the output. fn abbreviates(text: []const u8, is_zero: bool, options: FormatOptions) bool { if (options.max_integer_digits) |limit| { if (grouping.integerDigitCount(text) > limit) return true; } if (leadingFractionZeros(text)) |zeros| { if (zeros >= options.scientific_below_exponent) return true; } // Whatever the budgets say, never hand back a non-zero value rendered as // zero: 2^-70 as "0.00000000000000000000" loses it completely rather than // merely rounding it. Unreachable while `scientific_below_exponent` is at // or below `fraction_digits`, which is why it is a guard and not the rule. return !is_zero and isZeroText(text); } /// Fractional places before the first significant digit: 0 for "0.5", 3 for /// "0.000123". How far out the value starts, which is what decides whether /// fixed notation can show it. /// /// Null when a significant digit sits in the integer part, since such a value /// cannot be lost to leading zeros however narrow the window is. fn leadingFractionZeros(text: []const u8) ?usize { const dot = std.mem.indexOfScalar(u8, text, '.') orelse return null; if (!isZeroText(text[0..dot])) return null; var zeros: usize = 0; for (text[dot + 1 ..]) |ch| { if (ch != '0') break; zeros += 1; } return zeros; } /// Drop the zeros a fixed precision pads with, and the point if nothing is left /// after it. An exact expansion never has them, so this keeps the two arms /// producing the same text for the same value. fn trimTrailingZeros(text: []const u8) []const u8 { if (std.mem.indexOfScalar(u8, text, '.') == null) return text; const trimmed = std.mem.trimEnd(u8, text, "0"); if (std.mem.endsWith(u8, trimmed, ".")) return trimmed[0 .. trimmed.len - 1]; return trimmed; } /// 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. /// /// The float arm asks this to decide whether the shortest round-trip form fits /// the caller's budget or has to be rounded to it. The exact arm never needs it: /// `toDecimalString` divides out to the budget and stops. 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, .scientific_below_exponent = 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, .scientific_below_exponent = 15, .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, .scientific_below_exponent = 15, .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, .scientific_below_exponent = 15, .max_integer_digits = null, .significant_digits = 17, .separators = false, }; /// What the float view asks for: a wide digit budget and a narrow window, so a /// value shows every digit an f64 has, and one starting past the sixth fractional /// place goes scientific instead of printing leading zeros. const compact_budget: Number.FormatOptions = .{ .fraction_digits = 17, .scientific_below_exponent = 6, .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); // Inside the window: 14 leading zeros, so fixed notation still shows it. One // decade smaller and it abbreviates, whatever the fractional budget allows. try expectRenderExact("0.000000000000001", "0.000000000000001", 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: past the window a value abbreviates, whichever tier it came from" { // The two budgets answer different questions, and this is the one the window // answers: a value whose first significant digit falls past the fifteenth // fractional place has no fixed form worth printing, so both tiers abbreviate // and the same magnitude reads the same way either way. var exact = try Number.parse(alloc, "0.00000000000000015"); defer exact.deinit(); try expectRender("1.5e-16", true, exact, grouped_budget); try expectRender("1.5e-16", true, Number.fromFloat(1.5e-16), grouped_budget); // Before the window was separate from the budget, this band was where the // tiers disagreed: an exact value kept two significant digits in fixed text // while a float of the same size went scientific and kept seventeen. var band = try Number.parse(alloc, "0.00000000000000000033333333333333333"); defer band.deinit(); try expectRender("3.3333333333333333e-19", true, band, grouped_budget); // The nearest f64 to that decimal is a slightly different number, so its last // digit differs. The form is what this is about: both abbreviate. try expectRender("3.3333333333333334e-19", true, Number.fromFloat(3.3333333333333333e-19), grouped_budget); try expectRender("1.5e-21", true, Number.fromFloat(1.5e-21), grouped_budget); } test "render: inside the window a value longer than the budget is rounded, not abbreviated" { // 13 leading zeros, so fixed notation applies; 30 fractional digits, so the // 20-digit budget rounds it. The float is rounded the same way an exact // expansion is, and both produce the same text. var exact = try Number.parse(alloc, "0.000000000000031415926535897932"); defer exact.deinit(); try expectRender("0.00000000000003141593", true, exact, grouped_budget); try expectRender("0.00000000000003141593", true, Number.fromFloat(std.math.pi * 1e-14), grouped_budget); } test "render: a float shorter than the budget keeps every digit it has" { try expectRender("0.001", false, Number.fromFloat(0.001), grouped_budget); try expectRender("0.1", false, Number.fromFloat(0.1), grouped_budget); // 16 digits, inside a 20-digit budget, so nothing is rounded away. try expectRender("0.3333333333333333", false, Number.fromFloat(1.0 / 3.0), 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')); }