add rational number operations capability

This commit is contained in:
Emil Lerch 2026-07-27 18:26:11 -07:00
parent 690fa18e90
commit 83857cebbc
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 1862 additions and 0 deletions

View file

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

656
engine/src/number.zig Normal file
View file

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

1198
engine/src/rational.zig Normal file

File diff suppressed because it is too large Load diff