1483 lines
54 KiB
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'));
|
|
}
|