From 83857cebbc2e3d7a6501524c495194ea9c8f6ba2 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 27 Jul 2026 18:26:11 -0700 Subject: [PATCH] add rational number operations capability --- engine/src/engine.zig | 8 + engine/src/number.zig | 656 +++++++++++++++++++++ engine/src/rational.zig | 1198 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1862 insertions(+) create mode 100644 engine/src/number.zig create mode 100644 engine/src/rational.zig diff --git a/engine/src/engine.zig b/engine/src/engine.zig index c4eff07..fe4fd67 100644 --- a/engine/src/engine.zig +++ b/engine/src/engine.zig @@ -13,6 +13,10 @@ pub const programmer = @import("programmer.zig"); pub const formatter = @import("formatter.zig"); pub const float_interp = @import("float_interp.zig"); pub const units = @import("units.zig"); +// Exact numeric model (design.md 2.7). Not yet wired into the evaluator; see +// Task 2.0b. Exported here so its tests run as part of `zig build test`. +pub const rational = @import("rational.zig"); +pub const number = @import("number.zig"); // Re-export primary types for convenience pub const Value = types.Value; @@ -39,6 +43,10 @@ pub const ConvertResult = units.ConvertResult; pub const convert = units.convert; pub const findUnit = units.findUnit; +// Exact numeric model +pub const Rational = rational.Rational; +pub const Number = number.Number; + test { std.testing.refAllDecls(@This()); } diff --git a/engine/src/number.zig b/engine/src/number.zig new file mode 100644 index 0000000..fa3fd57 --- /dev/null +++ b/engine/src/number.zig @@ -0,0 +1,656 @@ +//! 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 Rational = rational.Rational; + +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 -- + + pub fn fromRational(value: Rational) Number { + return .{ .exact = value }; + } + + 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 }, + }; + } + + // -- Queries -- + + pub fn isExact(self: Number) bool { + return self == .exact; + } + + /// 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, + }; + } + + // -- Demotion -- + + /// Apply the growth cap: an exact value with an oversized denominator is + /// converted to inexact. Takes ownership of `value`. + fn capped(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 }; + } + + // -- 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 capped(allocator, result); + } + return .{ .inexact = floatOp(a.toFloat(allocator), b.toFloat(allocator)) }; + } + + 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 capped(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. + pub fn sqrt(allocator: Allocator, a: Number) Error!Number { + if (a == .exact and !a.exact.isNegative()) { + if (try Rational.sqrtExact(allocator, a.exact)) |root| { + return capped(allocator, root); + } + } + return .{ .inexact = @sqrt(a.toFloat(allocator)) }; + } + + /// Apply a float-only function, always producing an inexact result. This is + /// the single entry point for transcendentals, so the fallback boundary is + /// visible in one place. + pub fn applyFloatFn(allocator: Allocator, a: Number, comptime f: fn (f64) f64) Number { + return .{ .inexact = f(a.toFloat(allocator)) }; + } + + // -- 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; + } + + // -- Rendering -- + + pub const Display = struct { + /// Rendered text. Caller owns the memory. + text: []u8, + /// False when the text is a rounded approximation of the true value, + /// either because the value is inexact or because an exact value has a + /// non-terminating decimal expansion. + exact: bool, + }; + + /// Render for display. Exact values with terminating expansions print + /// exactly; everything else is rounded to `max_digits` and flagged. + pub fn toDecimalString(self: Number, allocator: Allocator, max_digits: usize) Error!Display { + switch (self) { + .exact => |r| { + const d = try r.toDecimalString(allocator, max_digits); + return .{ .text = d.text, .exact = d.exact }; + }, + .inexact => |f| { + const text = std.fmt.allocPrint(allocator, "{d}", .{f}) catch + return Error.OutOfMemory; + return .{ .text = text, .exact = false }; + }, + } + } + + /// Render an exact value as a fraction, or null when the value is inexact or + /// a whole number (where a fraction adds nothing). + pub fn toFractionString(self: Number, allocator: Allocator) Error!?[]u8 { + return switch (self) { + .exact => |r| if (r.isInteger()) null else try r.toFractionString(allocator), + .inexact => null, + }; + } +}; + +// -- Tests -- + +const testing = std.testing; +const alloc = testing.allocator; + +fn expectDecimal(expected: []const u8, expected_exact: bool, n: Number, digits: usize) !void { + const d = try n.toDecimalString(alloc, digits); + defer alloc.free(d.text); + try testing.expectEqualStrings(expected, d.text); + try testing.expectEqual(expected_exact, d.exact); +} + +test "parse produces an exact value" { + var n = try Number.parse(alloc, "0.1"); + defer n.deinit(); + try testing.expect(n.isExact()); + 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.isExact()); +} + +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.isExact()); + 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.isExact()); + + var product = try Number.mul(alloc, b, a); + defer product.deinit(); + try testing.expect(!product.isExact()); +} + +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.isExact()); + 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.isExact()); +} + +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.isExact()); + + var back = try Number.mul(alloc, third, three); + defer back.deinit(); + try testing.expect(back.isExact()); + 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.isExact()); + try expectDecimal("-0.25", true, n, 20); + + var b = try Number.abs(alloc, n); + defer b.deinit(); + try testing.expect(b.isExact()); + 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.isExact()); + 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.isExact()); + 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.isExact()); + 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.isExact()); + 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.isExact()); + 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.isExact()); + try testing.expectApproxEqAbs(@as(f64, std.math.sqrt2), r2.toFloat(alloc), 1e-15); +} + +test "sqrt: negative input falls back to a float NaN" { + var neg = try Number.fromInt(alloc, -4); + defer neg.deinit(); + var r = try Number.sqrt(alloc, neg); + defer r.deinit(); + try testing.expect(!r.isExact()); + try testing.expect(std.math.isNan(r.toFloat(alloc))); +} + +test "applyFloatFn always yields inexact" { + var one = try Number.fromInt(alloc, 1); + defer one.deinit(); + var r = Number.applyFloatFn(alloc, one, floatLog2); + defer r.deinit(); + try testing.expect(!r.isExact()); + try testing.expectEqual(@as(f64, 0.0), r.toFloat(alloc)); +} + +fn floatLog2(x: f64) f64 { + return @log2(x); +} + +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.isExact()) break; + _ = &next; + } + try testing.expect(!value.isExact()); +} + +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.isExact()); +} + +test "toDecimalString: inexact values are always flagged approximate" { + var a = Number.fromFloat(0.5); + defer a.deinit(); + const d = try a.toDecimalString(alloc, 20); + defer alloc.free(d.text); + try testing.expect(!d.exact); + try testing.expectEqualStrings("0.5", d.text); +} + +test "toDecimalString: 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 "toFractionString: exact non-integers only" { + 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 frac = (try third.toFractionString(alloc)).?; + defer alloc.free(frac); + try testing.expectEqualStrings("1/3", frac); + + // Integers and inexact values have no useful fraction form. + try testing.expect((try one.toFractionString(alloc)) == null); + var inexact = Number.fromFloat(0.25); + defer inexact.deinit(); + try testing.expect((try inexact.toFractionString(alloc)) == null); +} + +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.isExact()); + 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.isExact()); +} diff --git a/engine/src/rational.zig b/engine/src/rational.zig new file mode 100644 index 0000000..232609c --- /dev/null +++ b/engine/src/rational.zig @@ -0,0 +1,1198 @@ +//! Exact rational arithmetic for Tally. +//! +//! A `Rational` is `num / den` over arbitrary-precision integers, always kept +//! reduced with `den > 0`. Addition, subtraction, multiplication, division and +//! integer powers are closed and exact: no rounding occurs anywhere. +//! +//! This is the "exact" tier of the numeric model described in design.md 2.7. +//! Rationals cannot represent irrational results (`sqrt(2)`, `pi`), which is why +//! `number.zig` layers a float fallback on top. +//! +//! Why rationals rather than a decimal type: every decimal value is a rational, +//! so rationals strictly subsume decimal128 in expressiveness, and unlike decimal +//! they never round (`1/3` is exact, and `(1/3) * 3` is exactly 1). See +//! design.md 2.7.3. +//! +//! API style: operations return a fresh `Rational` and take an allocator, which +//! suits the evaluator's arena. Callers not using an arena must `deinit`. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Managed = std.math.big.int.Managed; +const Order = std.math.Order; + +pub const Error = error{ + OutOfMemory, + DivisionByZero, + InvalidNumber, + /// The exponent of an integer power did not fit the supported range. + ExponentTooLarge, +}; + +pub const Rational = struct { + /// Numerator. Carries the sign of the value. + num: Managed, + /// Denominator. Always strictly positive. + den: Managed, + + // -- Construction -- + + /// Zero (`0/1`). + pub fn initZero(allocator: Allocator) Error!Rational { + return .{ + .num = try Managed.initSet(allocator, 0), + .den = try Managed.initSet(allocator, 1), + }; + } + + /// An integer value (`value/1`). + pub fn initInt(allocator: Allocator, value: anytype) Error!Rational { + return .{ + .num = try Managed.initSet(allocator, value), + .den = try Managed.initSet(allocator, 1), + }; + } + + /// A ratio, reduced on construction. Errors if `den` is zero. + pub fn initRatio(allocator: Allocator, num: anytype, den: anytype) Error!Rational { + var result: Rational = .{ + .num = try Managed.initSet(allocator, num), + .den = try Managed.initSet(allocator, den), + }; + errdefer result.deinit(); + if (result.den.eqlZero()) return Error.DivisionByZero; + try result.normalize(allocator); + return result; + } + + /// Take ownership of two `Managed` values, normalizing them. + fn initOwned(allocator: Allocator, num: Managed, den: Managed) Error!Rational { + var result: Rational = .{ .num = num, .den = den }; + errdefer result.deinit(); + if (result.den.eqlZero()) return Error.DivisionByZero; + try result.normalize(allocator); + return result; + } + + pub fn deinit(self: *Rational) void { + self.num.deinit(); + self.den.deinit(); + } + + pub fn clone(self: Rational) Error!Rational { + var num = try self.num.clone(); + errdefer num.deinit(); + const den = try self.den.clone(); + return .{ .num = num, .den = den }; + } + + /// Reduce by the GCD and force the sign onto the numerator. + fn normalize(self: *Rational, allocator: Allocator) Error!void { + if (self.num.eqlZero()) { + try self.den.set(1); + self.num.setSign(true); + return; + } + + // Move the sign to the numerator. + if (!self.den.isPositive()) { + self.num.negate(); + self.den.negate(); + } + + var g = try Managed.init(allocator); + defer g.deinit(); + try g.gcd(&self.num, &self.den); + + // Nothing to do when already coprime. + if (g.toConst().orderAgainstScalar(1) == .eq) return; + + var q = try Managed.init(allocator); + defer q.deinit(); + var r = try Managed.init(allocator); + defer r.deinit(); + + try q.divFloor(&r, &self.num, &g); + try self.num.copy(q.toConst()); + try q.divFloor(&r, &self.den, &g); + try self.den.copy(q.toConst()); + } + + // -- Parsing -- + + /// Parse a decimal numeric literal exactly. + /// + /// Accepts an optional sign, digits with an optional fractional part, and an + /// optional decimal exponent: `42`, `-1.25`, `1e5`, `1.5e-3`. Underscores, + /// commas and spaces are ignored as digit separators, matching the + /// tokenizer's accepted forms. + /// + /// The result is exact: `0.1` becomes `1/10`, not the nearest binary float. + pub fn parseDecimal(allocator: Allocator, text: []const u8) Error!Rational { + var buf: [512]u8 = undefined; + var len: usize = 0; + for (text) |c| { + if (c == '_' or c == ',' or c == ' ') continue; + if (len >= buf.len) return Error.InvalidNumber; + buf[len] = c; + len += 1; + } + var s = buf[0..len]; + if (s.len == 0) return Error.InvalidNumber; + + var negative = false; + if (s[0] == '+' or s[0] == '-') { + negative = s[0] == '-'; + s = s[1..]; + if (s.len == 0) return Error.InvalidNumber; + } + + // Split off the exponent. + var exponent: i64 = 0; + var mantissa = s; + if (std.mem.indexOfAny(u8, s, "eE")) |e_idx| { + mantissa = s[0..e_idx]; + const exp_text = s[e_idx + 1 ..]; + if (exp_text.len == 0) return Error.InvalidNumber; + exponent = std.fmt.parseInt(i64, exp_text, 10) catch return Error.InvalidNumber; + if (exponent > 100_000 or exponent < -100_000) return Error.ExponentTooLarge; + } + if (mantissa.len == 0) return Error.InvalidNumber; + + // Split the mantissa on the decimal point. + var digits_buf: [512]u8 = undefined; + var digits_len: usize = 0; + var frac_digits: usize = 0; + var seen_dot = false; + for (mantissa) |c| { + if (c == '.') { + if (seen_dot) return Error.InvalidNumber; + seen_dot = true; + continue; + } + if (c < '0' or c > '9') return Error.InvalidNumber; + if (digits_len >= digits_buf.len) return Error.InvalidNumber; + digits_buf[digits_len] = c; + digits_len += 1; + if (seen_dot) frac_digits += 1; + } + if (digits_len == 0) return Error.InvalidNumber; + + var num = try Managed.init(allocator); + errdefer num.deinit(); + num.setString(10, digits_buf[0..digits_len]) catch return Error.InvalidNumber; + + var den = try Managed.initSet(allocator, 1); + errdefer den.deinit(); + + // value = digits / 10^frac_digits * 10^exponent + const net_exp: i64 = exponent - @as(i64, @intCast(frac_digits)); + if (net_exp != 0) { + const magnitude: u64 = @intCast(@abs(net_exp)); + if (magnitude > std.math.maxInt(u32)) return Error.ExponentTooLarge; + var scale = try Managed.initSet(allocator, 10); + defer scale.deinit(); + try scale.pow(&scale, @intCast(magnitude)); + if (net_exp > 0) { + try num.mul(&num, &scale); + } else { + try den.mul(&den, &scale); + } + } + + if (negative) num.negate(); + return initOwned(allocator, num, den); + } + + // -- Predicates -- + + pub fn isZero(self: Rational) bool { + return self.num.eqlZero(); + } + + pub fn isNegative(self: Rational) bool { + return !self.num.isPositive() and !self.num.eqlZero(); + } + + /// True when the denominator is 1, i.e. the value is a whole number. + pub fn isInteger(self: Rational) bool { + return self.den.toConst().orderAgainstScalar(1) == .eq; + } + + /// Size of the denominator in bits, used for the demotion policy. + pub fn denBitCount(self: Rational) usize { + return self.den.bitCountAbs(); + } + + /// True when the value has a terminating decimal expansion, i.e. the + /// denominator's only prime factors are 2 and 5. Such values print exactly. + pub fn isTerminating(self: Rational, allocator: Allocator) Error!bool { + var d = try self.den.clone(); + defer d.deinit(); + + var q = try Managed.init(allocator); + defer q.deinit(); + var r = try Managed.init(allocator); + defer r.deinit(); + + for ([_]u8{ 2, 5 }) |factor| { + var f = try Managed.initSet(allocator, factor); + defer f.deinit(); + while (true) { + try q.divFloor(&r, &d, &f); + if (!r.eqlZero()) break; + try d.copy(q.toConst()); + } + } + return d.toConst().orderAgainstScalar(1) == .eq; + } + + // -- Comparison -- + + /// Compare two rationals: `a <=> b`. + pub fn order(allocator: Allocator, a: Rational, b: Rational) Error!Order { + // a/b vs c/d -> a*d vs c*b (denominators are positive, so the + // inequality direction is preserved) + var left = try Managed.init(allocator); + defer left.deinit(); + var right = try Managed.init(allocator); + defer right.deinit(); + try left.mul(&a.num, &b.den); + try right.mul(&b.num, &a.den); + return left.order(right); + } + + pub fn eql(allocator: Allocator, a: Rational, b: Rational) Error!bool { + return (try order(allocator, a, b)) == .eq; + } + + // -- Arithmetic -- + + pub fn add(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + // a/b + c/d = (a*d + c*b) / (b*d) + var left = try Managed.init(allocator); + defer left.deinit(); + var right = try Managed.init(allocator); + defer right.deinit(); + try left.mul(&a.num, &b.den); + try right.mul(&b.num, &a.den); + + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.add(&left, &right); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.den); + + return initOwned(allocator, num, den); + } + + pub fn sub(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + var left = try Managed.init(allocator); + defer left.deinit(); + var right = try Managed.init(allocator); + defer right.deinit(); + try left.mul(&a.num, &b.den); + try right.mul(&b.num, &a.den); + + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.sub(&left, &right); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.den); + + return initOwned(allocator, num, den); + } + + pub fn mul(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.mul(&a.num, &b.num); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.den); + + return initOwned(allocator, num, den); + } + + pub fn div(allocator: Allocator, a: Rational, b: Rational) Error!Rational { + if (b.isZero()) return Error.DivisionByZero; + + var num = try Managed.init(allocator); + errdefer num.deinit(); + try num.mul(&a.num, &b.den); + + var den = try Managed.init(allocator); + errdefer den.deinit(); + try den.mul(&a.den, &b.num); + + return initOwned(allocator, num, den); + } + + pub fn negate(allocator: Allocator, a: Rational) Error!Rational { + var result = try a.clone(); + errdefer result.deinit(); + result.num.negate(); + _ = allocator; + return result; + } + + pub fn abs(allocator: Allocator, a: Rational) Error!Rational { + var result = try a.clone(); + errdefer result.deinit(); + result.num.abs(); + _ = allocator; + return result; + } + + /// Raise to an integer power. A negative exponent takes the reciprocal, + /// which is exact for rationals. + pub fn powInt(allocator: Allocator, a: Rational, exponent: i64) Error!Rational { + if (exponent == 0) return initInt(allocator, 1); + + const magnitude: u64 = @intCast(@abs(exponent)); + // Guard against absurd exponents that would exhaust memory. 2^(2^20) + // is already a 128 KiB integer. + if (magnitude > 1_000_000) return Error.ExponentTooLarge; + const e: u32 = @intCast(magnitude); + + if (a.isZero()) { + if (exponent < 0) return Error.DivisionByZero; + return initInt(allocator, 0); + } + + var num = try a.num.clone(); + errdefer num.deinit(); + var den = try a.den.clone(); + errdefer den.deinit(); + try num.pow(&num, e); + try den.pow(&den, e); + + if (exponent < 0) { + // Reciprocal: swap, then let normalize fix the sign. + return initOwned(allocator, den, num); + } + return initOwned(allocator, num, den); + } + + /// Exact square root, or null when the value is not a perfect square of a + /// rational. Used to keep `sqrt(4)` exact while `sqrt(2)` falls back. + pub fn sqrtExact(allocator: Allocator, a: Rational) Error!?Rational { + if (a.isNegative()) return null; + if (a.isZero()) return try initInt(allocator, 0); + + var num_root = try Managed.init(allocator); + errdefer num_root.deinit(); + var den_root = try Managed.init(allocator); + errdefer den_root.deinit(); + + // Guarded by the isNegative check above, so a negative input is impossible. + num_root.sqrt(&a.num) catch |err| switch (err) { + error.SqrtOfNegativeNumber => unreachable, + else => |e| return e, + }; + den_root.sqrt(&a.den) catch |err| switch (err) { + error.SqrtOfNegativeNumber => unreachable, + else => |e| return e, + }; + + // Verify: big.int sqrt truncates, so square the roots and compare. + var check = try Managed.init(allocator); + defer check.deinit(); + try check.mul(&num_root, &num_root); + if (!check.eql(a.num)) { + num_root.deinit(); + den_root.deinit(); + return null; + } + try check.mul(&den_root, &den_root); + if (!check.eql(a.den)) { + num_root.deinit(); + den_root.deinit(); + return null; + } + + return try initOwned(allocator, num_root, den_root); + } + + // -- Conversion -- + + /// Convert to the nearest f64. + /// + /// Scales the numerator so the quotient carries enough bits to round + /// correctly, rather than dividing two separately-rounded floats (which + /// would lose precision twice and overflow for large values). + pub fn toFloat(self: Rational, allocator: Allocator) f64 { + if (self.isZero()) return 0; + + const negative = self.isNegative(); + + var num = self.num.clone() catch return std.math.nan(f64); + defer { + var n = num; + n.deinit(); + } + num.abs(); + + const num_bits: i64 = @intCast(num.bitCountAbs()); + const den_bits: i64 = @intCast(self.den.bitCountAbs()); + + // Aim for ~72 significant bits in the quotient, comfortably more than + // f64's 53, so a single final rounding is correct. + const target: i64 = 72; + const shift: i64 = target - (num_bits - den_bits); + + var scaled = Managed.init(allocator) catch return std.math.nan(f64); + defer scaled.deinit(); + + if (shift > 0) { + if (shift > 1 << 20) return if (negative) -0.0 else 0.0; + scaled.shiftLeft(&num, @intCast(shift)) catch return std.math.nan(f64); + } else { + scaled.shiftRight(&num, @intCast(-shift)) catch return std.math.nan(f64); + } + + var q = Managed.init(allocator) catch return std.math.nan(f64); + defer q.deinit(); + var r = Managed.init(allocator) catch return std.math.nan(f64); + defer r.deinit(); + q.divFloor(&r, &scaled, &self.den) catch return std.math.nan(f64); + + const quotient, _ = q.toFloat(f64, .nearest_even); + const exponent: i32 = @intCast(-shift); + const result = std.math.ldexp(quotient, exponent); + return if (negative) -result else result; + } + + /// Render as an exact fraction, e.g. "781250/12573" or "7" for integers. + /// Caller owns the returned memory. + pub fn toFractionString(self: Rational, allocator: Allocator) Error![]u8 { + const num_str = try decimalDigits(self.num, allocator); + defer allocator.free(num_str); + if (self.isInteger()) return allocator.dupe(u8, num_str); + const den_str = try decimalDigits(self.den, allocator); + defer allocator.free(den_str); + return std.fmt.allocPrint(allocator, "{s}/{s}", .{ num_str, den_str }) catch + Error.OutOfMemory; + } + + pub const DecimalResult = struct { + /// Decimal text. Caller owns the memory. + text: []u8, + /// False when the expansion was truncated at `max_digits` and rounded, + /// i.e. the text is an approximation of the exact value. + exact: bool, + }; + + /// Render as decimal text by long division. + /// + /// Terminating expansions print exactly and report `exact = true`. Repeating + /// expansions are rounded half-up at `max_digits` fractional digits and + /// report `exact = false`, so a frontend can mark them as approximate. + pub fn toDecimalString(self: Rational, allocator: Allocator, max_digits: usize) Error!DecimalResult { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + + if (self.isNegative()) try out.append(allocator, '-'); + + var work = try self.num.clone(); + defer work.deinit(); + work.abs(); + + var q = try Managed.init(allocator); + defer q.deinit(); + var rem = try Managed.init(allocator); + defer rem.deinit(); + + // Integer part. + try q.divFloor(&rem, &work, &self.den); + const int_str = try decimalDigits(q, allocator); + defer allocator.free(int_str); + try out.appendSlice(allocator, int_str); + + if (rem.eqlZero()) { + return .{ .text = try out.toOwnedSlice(allocator), .exact = true }; + } + + try out.append(allocator, '.'); + + var ten = try Managed.initSet(allocator, 10); + defer ten.deinit(); + + var digits: usize = 0; + var exact = true; + while (digits < max_digits) { + try rem.mul(&rem, &ten); + try q.divFloor(&rem, &rem, &self.den); + const digit = q.toInt(u8) catch 0; + try out.append(allocator, '0' + digit); + digits += 1; + if (rem.eqlZero()) break; + } else { + // Ran out of digits with a remainder left: the text is truncated. + exact = false; + } + + if (!exact) { + // Round half-up: compare 2*remainder against the denominator. + var doubled = try Managed.init(allocator); + defer doubled.deinit(); + var two = try Managed.initSet(allocator, 2); + defer two.deinit(); + try doubled.mul(&rem, &two); + if (doubled.order(self.den) != .lt) { + roundUpDecimal(out.items); + } + } + + // Trim any trailing zeros produced by an exact expansion. + if (exact) { + var end = out.items.len; + while (end > 0 and out.items[end - 1] == '0') end -= 1; + if (end > 0 and out.items[end - 1] == '.') end -= 1; + out.shrinkRetainingCapacity(end); + } + + return .{ .text = try out.toOwnedSlice(allocator), .exact = exact }; + } +}; + +/// Base-10 digits of a big integer. Base 10 is always valid, so the only real +/// failure mode is allocation. +fn decimalDigits(m: Managed, allocator: Allocator) Error![]u8 { + return m.toString(allocator, 10, .lower) catch |err| switch (err) { + error.InvalidBase => unreachable, + else => |e| return e, + }; +} + +/// Propagate a half-up rounding carry through a decimal string in place. +/// Only called when the digits produced can absorb the carry, which is +/// guaranteed here because the integer part is present. +fn roundUpDecimal(text: []u8) void { + var i = text.len; + while (i > 0) { + i -= 1; + const c = text[i]; + if (c == '.' or c == '-') continue; + if (c != '9') { + text[i] = c + 1; + return; + } + text[i] = '0'; + } +} + +// -- Tests -- + +const testing = std.testing; +const alloc = testing.allocator; + +fn expectFrac(expected: []const u8, r: Rational) !void { + const s = try r.toFractionString(alloc); + defer alloc.free(s); + try testing.expectEqualStrings(expected, s); +} + +fn expectDecimal(expected: []const u8, expected_exact: bool, r: Rational, digits: usize) !void { + const d = try r.toDecimalString(alloc, digits); + defer alloc.free(d.text); + try testing.expectEqualStrings(expected, d.text); + try testing.expectEqual(expected_exact, d.exact); +} + +test "initInt and fraction rendering" { + var a = try Rational.initInt(alloc, 42); + defer a.deinit(); + try expectFrac("42", a); + try testing.expect(a.isInteger()); + try testing.expect(!a.isZero()); +} + +test "initZero" { + var z = try Rational.initZero(alloc); + defer z.deinit(); + try testing.expect(z.isZero()); + try testing.expect(z.isInteger()); + try expectFrac("0", z); +} + +test "initRatio reduces on construction" { + var a = try Rational.initRatio(alloc, 6, 8); + defer a.deinit(); + try expectFrac("3/4", a); +} + +test "initRatio normalizes a negative denominator onto the numerator" { + var a = try Rational.initRatio(alloc, 1, -2); + defer a.deinit(); + try expectFrac("-1/2", a); + try testing.expect(a.isNegative()); +} + +test "initRatio rejects a zero denominator" { + try testing.expectError(Error.DivisionByZero, Rational.initRatio(alloc, 1, 0)); +} + +test "initRatio reduces to an integer" { + var a = try Rational.initRatio(alloc, 10, 5); + defer a.deinit(); + try expectFrac("2", a); + try testing.expect(a.isInteger()); +} + +test "parseDecimal: plain integer" { + var a = try Rational.parseDecimal(alloc, "1234"); + defer a.deinit(); + try expectFrac("1234", a); +} + +test "parseDecimal: 0.1 is exactly one tenth" { + var a = try Rational.parseDecimal(alloc, "0.1"); + defer a.deinit(); + try expectFrac("1/10", a); +} + +test "parseDecimal: negative decimal" { + var a = try Rational.parseDecimal(alloc, "-1.25"); + defer a.deinit(); + try expectFrac("-5/4", a); +} + +test "parseDecimal: exponent forms" { + var a = try Rational.parseDecimal(alloc, "1e5"); + defer a.deinit(); + try expectFrac("100000", a); + + var b = try Rational.parseDecimal(alloc, "1.5e-3"); + defer b.deinit(); + try expectFrac("3/2000", b); + + var c = try Rational.parseDecimal(alloc, "2.5E2"); + defer c.deinit(); + try expectFrac("250", c); +} + +test "parseDecimal: separators are ignored" { + var a = try Rational.parseDecimal(alloc, "1_000_000"); + defer a.deinit(); + try expectFrac("1000000", a); + + var b = try Rational.parseDecimal(alloc, "1,000"); + defer b.deinit(); + try expectFrac("1000", b); +} + +test "parseDecimal: integer beyond f64 precision stays exact" { + // This is the value standard mode currently mangles. + var a = try Rational.parseDecimal(alloc, "9007199254740993"); + defer a.deinit(); + try expectFrac("9007199254740993", a); +} + +test "parseDecimal: very large integer" { + var a = try Rational.parseDecimal(alloc, "99999999999999999999999999"); + defer a.deinit(); + try expectFrac("99999999999999999999999999", a); +} + +test "parseDecimal: rejects malformed input" { + try testing.expectError(Error.InvalidNumber, Rational.parseDecimal(alloc, "")); + try testing.expectError(Error.InvalidNumber, Rational.parseDecimal(alloc, "1.2.3")); + try testing.expectError(Error.InvalidNumber, Rational.parseDecimal(alloc, "abc")); + try testing.expectError(Error.InvalidNumber, Rational.parseDecimal(alloc, "-")); + try testing.expectError(Error.InvalidNumber, Rational.parseDecimal(alloc, "1e")); +} + +test "add: the classic binary float failure is exact here" { + var a = try Rational.parseDecimal(alloc, "0.1"); + defer a.deinit(); + var b = try Rational.parseDecimal(alloc, "0.2"); + defer b.deinit(); + var sum = try Rational.add(alloc, a, b); + defer sum.deinit(); + try expectFrac("3/10", sum); + try expectDecimal("0.3", true, sum, 20); +} + +test "add: 1.1 + 2.2" { + var a = try Rational.parseDecimal(alloc, "1.1"); + defer a.deinit(); + var b = try Rational.parseDecimal(alloc, "2.2"); + defer b.deinit(); + var sum = try Rational.add(alloc, a, b); + defer sum.deinit(); + try expectFrac("33/10", sum); + try expectDecimal("3.3", true, sum, 20); +} + +test "add: unlike denominators" { + var a = try Rational.initRatio(alloc, 1, 3); + defer a.deinit(); + var b = try Rational.initRatio(alloc, 1, 6); + defer b.deinit(); + var sum = try Rational.add(alloc, a, b); + defer sum.deinit(); + try expectFrac("1/2", sum); +} + +test "sub" { + var a = try Rational.initRatio(alloc, 1, 2); + defer a.deinit(); + var b = try Rational.initRatio(alloc, 1, 3); + defer b.deinit(); + var d = try Rational.sub(alloc, a, b); + defer d.deinit(); + try expectFrac("1/6", d); +} + +test "sub to zero normalizes the denominator" { + var a = try Rational.initRatio(alloc, 3, 7); + defer a.deinit(); + var d = try Rational.sub(alloc, a, a); + defer d.deinit(); + try testing.expect(d.isZero()); + try expectFrac("0", d); +} + +test "mul" { + var a = try Rational.initRatio(alloc, 2, 3); + defer a.deinit(); + var b = try Rational.initRatio(alloc, 3, 4); + defer b.deinit(); + var p = try Rational.mul(alloc, a, b); + defer p.deinit(); + try expectFrac("1/2", p); +} + +test "mul: one third times three is exactly one" { + var a = try Rational.initRatio(alloc, 1, 3); + defer a.deinit(); + var three = try Rational.initInt(alloc, 3); + defer three.deinit(); + var p = try Rational.mul(alloc, a, three); + defer p.deinit(); + try expectFrac("1", p); + try testing.expect(p.isInteger()); +} + +test "mul: 0.1 * 3" { + var a = try Rational.parseDecimal(alloc, "0.1"); + defer a.deinit(); + var three = try Rational.initInt(alloc, 3); + defer three.deinit(); + var p = try Rational.mul(alloc, a, three); + defer p.deinit(); + try expectDecimal("0.3", true, p, 20); +} + +test "div" { + var a = try Rational.initInt(alloc, 1); + defer a.deinit(); + var b = try Rational.initInt(alloc, 3); + defer b.deinit(); + var q = try Rational.div(alloc, a, b); + defer q.deinit(); + try expectFrac("1/3", q); +} + +test "div by zero errors" { + var a = try Rational.initInt(alloc, 1); + defer a.deinit(); + var z = try Rational.initZero(alloc); + defer z.deinit(); + try testing.expectError(Error.DivisionByZero, Rational.div(alloc, a, z)); +} + +test "div by a negative keeps the sign on the numerator" { + var a = try Rational.initInt(alloc, 1); + defer a.deinit(); + var b = try Rational.initInt(alloc, -3); + defer b.deinit(); + var q = try Rational.div(alloc, a, b); + defer q.deinit(); + try expectFrac("-1/3", q); +} + +test "negate and abs" { + var a = try Rational.initRatio(alloc, 3, 4); + defer a.deinit(); + var n = try Rational.negate(alloc, a); + defer n.deinit(); + try expectFrac("-3/4", n); + var b = try Rational.abs(alloc, n); + defer b.deinit(); + try expectFrac("3/4", b); +} + +test "powInt: positive exponent" { + var a = try Rational.initInt(alloc, 2); + defer a.deinit(); + var p = try Rational.powInt(alloc, a, 10); + defer p.deinit(); + try expectFrac("1024", p); +} + +test "powInt: 2^53 + 1 territory stays exact" { + var two = try Rational.initInt(alloc, 2); + defer two.deinit(); + var p = try Rational.powInt(alloc, two, 53); + defer p.deinit(); + var one = try Rational.initInt(alloc, 1); + defer one.deinit(); + var sum = try Rational.add(alloc, p, one); + defer sum.deinit(); + try expectFrac("9007199254740993", sum); +} + +test "powInt: negative exponent takes the reciprocal" { + var a = try Rational.initInt(alloc, 2); + defer a.deinit(); + var p = try Rational.powInt(alloc, a, -3); + defer p.deinit(); + try expectFrac("1/8", p); +} + +test "powInt: zero exponent is one" { + var a = try Rational.initRatio(alloc, 7, 9); + defer a.deinit(); + var p = try Rational.powInt(alloc, a, 0); + defer p.deinit(); + try expectFrac("1", p); +} + +test "powInt: rational base" { + var a = try Rational.initRatio(alloc, 2, 3); + defer a.deinit(); + var p = try Rational.powInt(alloc, a, 3); + defer p.deinit(); + try expectFrac("8/27", p); +} + +test "powInt: zero base" { + var z = try Rational.initZero(alloc); + defer z.deinit(); + var p = try Rational.powInt(alloc, z, 5); + defer p.deinit(); + try testing.expect(p.isZero()); + try testing.expectError(Error.DivisionByZero, Rational.powInt(alloc, z, -1)); +} + +test "powInt: rejects absurd exponents" { + var a = try Rational.initInt(alloc, 2); + defer a.deinit(); + try testing.expectError(Error.ExponentTooLarge, Rational.powInt(alloc, a, 2_000_000)); +} + +test "powInt: negative base with odd and even exponents" { + var a = try Rational.initInt(alloc, -2); + defer a.deinit(); + var odd = try Rational.powInt(alloc, a, 3); + defer odd.deinit(); + try expectFrac("-8", odd); + var even = try Rational.powInt(alloc, a, 2); + defer even.deinit(); + try expectFrac("4", even); +} + +test "sqrtExact: perfect squares stay exact" { + var four = try Rational.initInt(alloc, 4); + defer four.deinit(); + var r = (try Rational.sqrtExact(alloc, four)).?; + defer r.deinit(); + try expectFrac("2", r); +} + +test "sqrtExact: perfect rational square" { + var a = try Rational.initRatio(alloc, 9, 16); + defer a.deinit(); + var r = (try Rational.sqrtExact(alloc, a)).?; + defer r.deinit(); + try expectFrac("3/4", r); +} + +test "sqrtExact: non-squares return null so the caller can fall back" { + var two = try Rational.initInt(alloc, 2); + defer two.deinit(); + try testing.expect((try Rational.sqrtExact(alloc, two)) == null); + + var a = try Rational.initRatio(alloc, 1, 3); + defer a.deinit(); + try testing.expect((try Rational.sqrtExact(alloc, a)) == null); +} + +test "sqrtExact: zero and negatives" { + var z = try Rational.initZero(alloc); + defer z.deinit(); + var r = (try Rational.sqrtExact(alloc, z)).?; + defer r.deinit(); + try testing.expect(r.isZero()); + + var neg = try Rational.initInt(alloc, -4); + defer neg.deinit(); + try testing.expect((try Rational.sqrtExact(alloc, neg)) == null); +} + +test "order and eql" { + var a = try Rational.initRatio(alloc, 1, 3); + defer a.deinit(); + var b = try Rational.initRatio(alloc, 1, 2); + defer b.deinit(); + try testing.expectEqual(Order.lt, try Rational.order(alloc, a, b)); + try testing.expectEqual(Order.gt, try Rational.order(alloc, b, a)); + try testing.expectEqual(Order.eq, try Rational.order(alloc, a, a)); + try testing.expect(try Rational.eql(alloc, a, a)); + try testing.expect(!try Rational.eql(alloc, a, b)); +} + +test "order across signs" { + var neg = try Rational.initInt(alloc, -1); + defer neg.deinit(); + var pos = try Rational.initRatio(alloc, 1, 1000); + defer pos.deinit(); + try testing.expectEqual(Order.lt, try Rational.order(alloc, neg, pos)); +} + +test "order recognizes equal values with different representations" { + var a = try Rational.initRatio(alloc, 2, 4); + defer a.deinit(); + var b = try Rational.initRatio(alloc, 50, 100); + defer b.deinit(); + try testing.expect(try Rational.eql(alloc, a, b)); +} + +test "isTerminating" { + var tenth = try Rational.parseDecimal(alloc, "0.1"); + defer tenth.deinit(); + try testing.expect(try tenth.isTerminating(alloc)); + + var eighth = try Rational.initRatio(alloc, 1, 8); + defer eighth.deinit(); + try testing.expect(try eighth.isTerminating(alloc)); + + var third = try Rational.initRatio(alloc, 1, 3); + defer third.deinit(); + try testing.expect(!try third.isTerminating(alloc)); + + var seventh = try Rational.initRatio(alloc, 1, 7); + defer seventh.deinit(); + try testing.expect(!try seventh.isTerminating(alloc)); + + var whole = try Rational.initInt(alloc, 5); + defer whole.deinit(); + try testing.expect(try whole.isTerminating(alloc)); +} + +test "toDecimalString: terminating expansions are exact" { + var a = try Rational.initRatio(alloc, 1, 8); + defer a.deinit(); + try expectDecimal("0.125", true, a, 20); + + var b = try Rational.initInt(alloc, 7); + defer b.deinit(); + try expectDecimal("7", true, b, 20); + + var c = try Rational.initRatio(alloc, -3, 4); + defer c.deinit(); + try expectDecimal("-0.75", true, c, 20); +} + +test "toDecimalString: repeating expansions round and report inexact" { + var third = try Rational.initRatio(alloc, 1, 3); + defer third.deinit(); + try expectDecimal("0.333", false, third, 3); + + var twothirds = try Rational.initRatio(alloc, 2, 3); + defer twothirds.deinit(); + // 0.6666... rounds up at the cutoff + try expectDecimal("0.667", false, twothirds, 3); +} + +test "toDecimalString: rounding carries across digits" { + // 1/1000 with 2 digits: 0.001 -> rounds to 0.00 + var a = try Rational.initRatio(alloc, 999, 1000); + defer a.deinit(); + try expectDecimal("1.00", false, a, 2); +} + +test "toDecimalString: the unit conversion case is exact" { + // 12 inches in feet: 12 * (127/5000) / (381/1250) = exactly 1 + var twelve = try Rational.initInt(alloc, 12); + defer twelve.deinit(); + var inch = try Rational.initRatio(alloc, 127, 5000); + defer inch.deinit(); + var foot = try Rational.initRatio(alloc, 381, 1250); + defer foot.deinit(); + + var meters = try Rational.mul(alloc, twelve, inch); + defer meters.deinit(); + var feet = try Rational.div(alloc, meters, foot); + defer feet.deinit(); + + try expectFrac("1", feet); + try expectDecimal("1", true, feet, 20); +} + +test "toDecimalString: non-terminating conversion renders approximately" { + // 100 km in miles = 100000 / 1609.344 = 781250/12573 + var hundred_km = try Rational.initInt(alloc, 100000); + defer hundred_km.deinit(); + var mile = try Rational.parseDecimal(alloc, "1609.344"); + defer mile.deinit(); + var miles = try Rational.div(alloc, hundred_km, mile); + defer miles.deinit(); + + try expectFrac("781250/12573", miles); + const d = try miles.toDecimalString(alloc, 9); + defer alloc.free(d.text); + try testing.expect(!d.exact); + try testing.expect(std.mem.startsWith(u8, d.text, "62.13711922")); +} + +test "toFloat: simple values" { + var half = try Rational.initRatio(alloc, 1, 2); + defer half.deinit(); + try testing.expectEqual(@as(f64, 0.5), half.toFloat(alloc)); + + var z = try Rational.initZero(alloc); + defer z.deinit(); + try testing.expectEqual(@as(f64, 0.0), z.toFloat(alloc)); + + var neg = try Rational.initInt(alloc, -7); + defer neg.deinit(); + try testing.expectEqual(@as(f64, -7.0), neg.toFloat(alloc)); +} + +test "toFloat: a single rounding fixes the accumulated-error bug" { + // Exactly 3/10 rounded once is the f64 nearest 0.3, which prints as "0.3". + var a = try Rational.parseDecimal(alloc, "0.1"); + defer a.deinit(); + var b = try Rational.parseDecimal(alloc, "0.2"); + defer b.deinit(); + var sum = try Rational.add(alloc, a, b); + defer sum.deinit(); + try testing.expectEqual(@as(f64, 0.3), sum.toFloat(alloc)); + + // Whereas the f64 route accumulates error and misses. The values must be + // runtime-known: Zig folds float literals at comptime as `comptime_float`, + // which would not exercise f64 arithmetic at all. + var x: f64 = 0.1; + var y: f64 = 0.2; + _ = &x; + _ = &y; + try testing.expect(x + y != @as(f64, 0.3)); +} + +test "toFloat: thirds round correctly" { + var third = try Rational.initRatio(alloc, 1, 3); + defer third.deinit(); + try testing.expectApproxEqAbs(@as(f64, 1.0 / 3.0), third.toFloat(alloc), 1e-18); +} + +test "toFloat: large integers" { + var a = try Rational.parseDecimal(alloc, "9007199254740992"); + defer a.deinit(); + try testing.expectEqual(@as(f64, 9007199254740992.0), a.toFloat(alloc)); +} + +test "toFloat: magnitudes far beyond the scaling window" { + // When the numerator has far more bits than the denominator, toFloat must + // shift the numerator DOWN rather than up. Exercises the negative-shift + // branch of the scaling logic. + var big = try Rational.parseDecimal(alloc, "1e30"); + defer big.deinit(); + try testing.expectEqual(@as(f64, 1e30), big.toFloat(alloc)); + + var huge = try Rational.parseDecimal(alloc, "1e100"); + defer huge.deinit(); + try testing.expectEqual(@as(f64, 1e100), huge.toFloat(alloc)); + + var neg = try Rational.parseDecimal(alloc, "-1e40"); + defer neg.deinit(); + try testing.expectEqual(@as(f64, -1e40), neg.toFloat(alloc)); + + // 2^200 as an exact integer, converted down to the nearest f64. + var two = try Rational.initInt(alloc, 2); + defer two.deinit(); + var p = try Rational.powInt(alloc, two, 200); + defer p.deinit(); + try testing.expectEqual(std.math.ldexp(@as(f64, 1.0), 200), p.toFloat(alloc)); +} + +test "toFloat: a large numerator over a large denominator" { + // Both sides big, ratio small: the shift stays near zero. + var a = try Rational.parseDecimal(alloc, "1e40"); + defer a.deinit(); + var b = try Rational.parseDecimal(alloc, "1e39"); + defer b.deinit(); + var q = try Rational.div(alloc, a, b); + defer q.deinit(); + try testing.expectEqual(@as(f64, 10.0), q.toFloat(alloc)); +} + +test "toFloat: round trip through parseDecimal for a range of values" { + const cases = [_][]const u8{ "1", "0.5", "0.25", "2.75", "-3.125", "1024", "0.0625" }; + for (cases) |c| { + var r = try Rational.parseDecimal(alloc, c); + defer r.deinit(); + const f = r.toFloat(alloc); + const expected = try std.fmt.parseFloat(f64, c); + try testing.expectEqual(expected, f); + } +} + +test "denBitCount grows with the denominator" { + var small = try Rational.initRatio(alloc, 1, 2); + defer small.deinit(); + try testing.expectEqual(@as(usize, 2), small.denBitCount()); + + var big = try Rational.initRatio(alloc, 1, 1024); + defer big.deinit(); + try testing.expectEqual(@as(usize, 11), big.denBitCount()); + + var whole = try Rational.initInt(alloc, 99); + defer whole.deinit(); + try testing.expectEqual(@as(usize, 1), whole.denBitCount()); +} + +test "clone is independent" { + var a = try Rational.initRatio(alloc, 3, 7); + defer a.deinit(); + var b = try a.clone(); + defer b.deinit(); + try testing.expect(try Rational.eql(alloc, a, b)); + try expectFrac("3/7", b); +} + +test "chained arithmetic stays exact where f64 would drift" { + // (0.1 + 0.2) * 10 - 3 == 0 exactly + var a = try Rational.parseDecimal(alloc, "0.1"); + defer a.deinit(); + var b = try Rational.parseDecimal(alloc, "0.2"); + defer b.deinit(); + var ten = try Rational.initInt(alloc, 10); + defer ten.deinit(); + var three = try Rational.initInt(alloc, 3); + defer three.deinit(); + + var sum = try Rational.add(alloc, a, b); + defer sum.deinit(); + var scaled = try Rational.mul(alloc, sum, ten); + defer scaled.deinit(); + var result = try Rational.sub(alloc, scaled, three); + defer result.deinit(); + + try testing.expect(result.isZero()); + + // The f64 route does not reach zero. Runtime-known values are required so + // the arithmetic actually happens in f64 rather than being comptime-folded. + var x: f64 = 0.1; + var y: f64 = 0.2; + _ = &x; + _ = &y; + try testing.expect((x + y) * 10.0 - 3.0 != 0.0); +}